---
title: "Find people from public data"
method: POST
path: "/public/search"
tags: ["public"]
---

# Find people from public data

`POST /public/search`

Search for people through an anonymous public web search; no connected account is needed. Results are public data, never signed-in LinkedIn details (connection degree, who engaged with a post, whether someone is reachable by message). Found profiles are saved as contacts and render as a people list; narrate a one-line count. limit (1-300, default 15) is how many profiles ONE call targets: a specific number sets it, a volume word with no ceiling raises it toward 100-300. Pass every plausible title, seniority, or city as an array on the SAME filter field in ONE call. more:true continues the SAME filters past people already found, on whichever lane answered the first call: the public sweep, or the connected account when one ran. moreAvailable:false after a continuation means that lane is walked out: say so plainly and do not call more:true again. exhaustionNote, when present, says which wall was hit: relay it. duplicates counts people a continuation re-reached and dropped without re-charging; that is normal bookkeeping, never a failure. exhaustion:"axes-exhausted" means every angle was tried: suggest a real change, do not call more:true again. exhaustion:"batch-failed" is a temporary vendor hiccup. A signal that would show in a headline (years in role, a school, a tool, freelance) belongs in filters.keywords, not a visit-first pass. When they asked for somebody in particular rather than describing a crowd, pass their words as sentence ALONGSIDE the facets: that is what answers a named question directly instead of slicing a market. A result returns everybody it found, including people already in the contact list, marked completeness "already-a-contact": that is an ANSWER, never a reason to search again with other words. To REFINE a previous result (a second turn shaped like 'not these'), repeat the SAME filters with excludeWords or excludeProfiles rather than writing a new search: the refinement drops them before any per-person cost and reports excludedCount. Asked who somebody is, name them and say they are already saved.

## Request body

- object
  - `filters` object, required — Search facets. Provide at least one of title, location, company, industry, or keywords.
    - `title` union — Role or job title facet. One value or a list of alternatives.
      - string
      - string[]
    - `location` union — City, region, or country facet. One value or a list of alternatives.
      - string
      - string[]
    - `company` union — Company name facet. One value or a list of alternatives.
      - string
      - string[]
    - `industry` union — Industry facet. One value or a list of alternatives.
      - string
      - string[]
    - `seniority` union — Seniority facet. One value or a list of alternatives.
      - string
      - string[]
    - `keywords` string — Free-text keywords appended to the search query.
    - `countryCode` string — ISO alpha-2 country code used to scope results to one country.
  - `limit` integer — Target number of NEW profiles to fetch. People already in your contacts that the search also finds are listed additionally, marked already-a-contact, at no fetch cost: the contacts array can therefore exceed this number, which is the answer being complete rather than the limit being ignored.
  - `save` 'keep' | 'skip' | 'ask' — What happens to the people found. "skip" ONLY when you are certain this search is a step on the way somewhere else: one person or one company you need in order to then work on their posts or their audience, or a check of who somebody is. "ask" when you are not certain, and the person gets the choice. Otherwise "keep", which is the default: a search for people to reach is the list they are building. When they want only PART of what a search would return, narrow first and keep the narrow result; keeping everyone and then removing the unwanted writes people nobody asked for. The answer reports what was actually written: say that, never what you intended.
  - `chain` object — Optional two-step mode for DISCOVERING companies the caller does not already know by name (an industry, sector, or location ask): first find companies matching filters.industry/filters.location, then search this role inside each. Do NOT set this when specific companies are already named — pass them directly as filters.company instead (no chain needed): that searches those exact companies' roles directly, at lower cost and without a discovery step that can fail.
    - `role` string, required — Role searched inside each matched company.
    - `companiesLimit` integer — How many companies to search inside.
  - `more` boolean — Continue past the people an earlier search on these same filters already returned, instead of returning them again. What counts as already returned is the position the last search reached, kept server side.
  - `sentence` string — What the person actually asked, in their own words, when they asked for somebody in particular rather than describing a crowd: a named person, a role at a named organisation, everyone at one, or people who used to work there. Send it ALONGSIDE the facets, never instead of them. It is what lets the search answer a named question directly instead of slicing a market that has no such answer.
  - `companySize` union — Company-headcount constraint. Not searchable here: echoed under notSearched, results run wider.
    - string
    - string[]
  - `connectionDegree` union — Network-degree constraint. Not searchable here: echoed under notSearched, results run wider.
    - string
    - string[]
  - `verified` boolean — Verified-identity constraint. Not searchable here: echoed under notSearched.
  - `school` union — School as a structured filter (word-matching goes in filters.keywords instead). Not searchable here: echoed under notSearched.
    - string
    - string[]
  - `pastCompanies` union — Past employer as an employment record. Not searchable here: echoed under notSearched.
    - string
    - string[]
  - `profileLanguage` union — Profile-language constraint. Not searchable here: echoed under notSearched.
    - string
    - string[]
  - `companyHQ` union — Company-headquarters constraint. Not searchable here: echoed under notSearched.
    - string
    - string[]
  - `followersOf` union — Follows-this-creator constraint. Not searchable here: echoed under notSearched.
    - string
    - string[]
  - `excludeProfiles` union — Profile URLs or slugs to leave out, from rows already seen. Applied before any per-person fetch.
    - string
    - string[]
  - `excludeWords` union — Words or phrases that disqualify a row when they appear in its title or headline. Applied before any per-person fetch where possible.
    - string
    - string[]
  - `approveLinkedInSpend` boolean — Approval to run this search through the user's connected LinkedIn account when a facet needs it. Never pass true on your own: the result's needs quote is relayed to the user first, and their agreement in their next message is what this flag carries.

## Response `200`

Found and enriched people

- object
  - `source` 'public', required
  - `provider` string, required — The search provider that answered.
  - `query` string, required — The exact query sent, so a thin result set can be read rather than guessed at.
  - `existingCount` integer — How many of the people returned were already in your contacts. They ARE in `contacts` like everyone else, marked completeness "already-a-contact", and were not re-fetched because their profile was already read once. Absent when there were none. Not a reason to search again: the search already answered.
  - `askedFor` integer — How many results carry the words YOU typed, rather than the words the search expanded them into. Zero on a full result set that otherwise looks healthy is a strong caveat worth telling the reader about, most often meaning the job title or company named was not found. It is a caveat and not a verdict: a partial result, or a title nobody writes in a headline, can also read zero. Corroborate before saying the person or company does not exist.
  - `exhaustionNote` string — Why this result is thinner than its count suggests, in words ready to relay. Three causes: the ask itself matched nobody and these people are the closest the wider search reached (a continuation continues that wider search, not the original ask); a continuation reached the end of this search's or this company's list, so there is genuinely nobody further; or the connected account hit LinkedIn's monthly search cap, so the set is truncated rather than complete. Relay it rather than presenting the rows as the whole answer.
  - `laneNote` string — Present when the answer had to run on a different lane than the ask deserved, in product words: for example a company roster built from public mentions because no usable LinkedIn account was connected. Relay it verbatim; it already says what to do next.
  - `refused` 'nothing-to-search' | 'wants-companies' — Set when the request could not be searched at all. 'nothing-to-search' means nothing in it named anybody. 'wants-companies' means it asked for organisations, which this operation does not find: use the company search instead. Neither is a retry: say what happened and change the request.
  - `notFound` string[] — People you named by name that the search could not find. When this is set, no result is that person: say so rather than presenting the people returned as them.
  - `urlsFound` integer, required — Total profile URLs found.
  - `newUrls` integer, required — Profiles fetched on this call. People already in your contacts are returned too, from the search result, without being fetched again.
  - `hydrated` integer, required — Profiles fetched and enriched in this call.
  - `searches` integer — How many searches it took to find these people.
  - `searchesFailed` integer — Searches that did not reach the provider. When this is high the result says nothing about the market.
  - `moreAvailable` boolean — Whether asking again with more can still reach people you have not seen. Set on every lane, including a run through the connected account. False after a continuation means that lane is walked out, not that the search failed.
  - `exhaustion` 'more-available' | 'axes-exhausted' | 'batch-failed' — Finer than moreAvailable: 'axes-exhausted' means every search angle for this audience has genuinely been tried, calling again with more will not find anybody new. 'batch-failed' means this attempt could not reach the search provider, worth retrying. 'more-available' is the ordinary case.
  - `axesTried` object[] — What was actually tried, broken down by search angle, against what each angle could still do.
    - `axis` string, required — Which search angle this row covers, e.g. title, city, school, tool.
    - `queriesPlanned` integer, required — How many searches this angle could run in total.
    - `attempted` integer, required — How many of those have actually run so far.
    - `yielded` integer, required — New people this angle has contributed.
    - `exhausted` boolean, required — Whether this angle has nothing left to try.
  - `axesAvailable` object[] — Every search angle this audience supports and how many queries each could run — the full potential, whether or not it has been searched yet. Free to compute, no extra searches run to produce it.
    - `axis` string, required — A search angle this audience supports, e.g. title, city, school, tool.
    - `queries` integer, required — How many searches that angle could run in total.
  - `countryRiskNote` object — A non-blocking caveat about this market, e.g. a country whose profiles are hard to tell apart from a bigger anglophone one. Never a reason to withhold or shrink the result — surface it as a caution alongside the real count, never in place of one.
    - `risk` 'low' | 'medium' | 'high', required — How likely this country/title combination is to return false-positive matches from elsewhere.
    - `reason` string, required — Why, in plain language.
  - `rateLimited` integer — Profiles found but deferred because the scraping pool is busy.
  - `note` string — Explanation when part of the result set was deferred.
  - `stillRunning` boolean — The search outlived this request's watch window and is still working server side. Everything in this response is real and already found. Ask the SAME filters again shortly to collect more: that joins the running search rather than starting or paying for a new one.
  - `partial` boolean — The search stopped before finishing, so this is what it found, not the whole answer. Asking again starts a fresh search. Never present a partial result as everybody there is.
  - `progress` object — How far the running search has gotten, alongside stillRunning or partial.
    - `peopleFound` integer
    - `queriesRun` integer
  - `creditsExhausted` integer — Profiles found but not read because the workspace credit pool emptied part way through. Real matches, not the full result set.
  - `creditsUsed` integer — Credits this call spent from the workspace pool: search pages plus profile fetches that succeeded.
  - `duplicates` integer — People this continuation found again that an earlier call on the same filters already returned. They were filtered out without being re-fetched or re-charged.
  - `queries` string[] — Every query this call actually executed, so a multi-part ask can be read rather than guessed at. `query` above stays the single representative one.
  - `pooled` integer — How many of these people were linked into the target list or conversation. Zero alongside non-empty results means found but not saved to a list.
  - `poolTargeted` boolean — Whether a target list existed for this call at all; gives pooled its meaning.
  - `companies` object[] — Chain mode only: the companies discovered in step one, whose people step two searched.
    - `name` string, nullable
    - `url` string, nullable
  - `notSearched` object[] — Constraints the request carried that this search could not express. When present, the results ran WIDER than asked: everyone requested is still eligible to appear, plus others. Relay this plainly; never present the list as filtered by these.
    - `field` string, required — The constraint that could not be applied.
    - `value` string, required — What was asked for.
    - `why` string, required — Why it could not be applied, in plain words.
    - `effect` string, required — What that does to the results: wider than asked, never different.
  - `saved` object — What the save choice actually did, so claims about saving cite this rather than the request that was sent.
    - `count` integer, required — People written to the contact list by THIS call.
    - `decide` boolean — The caller was not sure whether to keep these people, so nobody was written and the person is offered the choice on the result itself. Do not tell them anything was kept, and do not ask them again in words.
  - `ambiguity` object[] — Organisation names in the ask that several real organisations share. The results may mix them: ask which one was meant before presenting a single answer as the answer.
    - `name` string, required — The organisation name that is ambiguous.
    - `note` string, required — What to ask before trusting a single row.
  - `needs` object — A quote, not an answer: this ask carries facets only the connected account can honour, and nothing was run or spent. Relay it plainly, ask the user, and repeat the call with approveLinkedInSpend true only after they agree. contacts is empty alongside this by construction.
    - `kind` 'linkedin_spend', required
    - `because` object[], required — The facets that need the connected account.
      - `field` string, required
      - `value` string, required
    - `estimate` object, required — What approving would spend, in LinkedIn searches.
      - `linkedinSearches` integer, required
    - `alternative` string, required — The no-spend path, ready to relay.
  - `honoured` string[] — On an approved account-lane run: the exact facets that were genuinely applied.
  - `union` object — Premium cohorts run both lanes in parallel and merge: the public sweep plus one exact account search, every account row marked lane linkedin. Present when the account arm added people; both spends are receipted.
    - `added` integer, required — Rows the connected-account arm added beyond what the public sweep found.
    - `linkedinSearches` integer, required — LinkedIn searches the arm spent.
  - `approvedBy` 'user' | 'premium' — Who approved the account-lane run: the user's explicit yes, or the account's LinkedIn Premium (no monthly search cap, so exact runs directly). Relay which.
  - `linkedinSearches` integer — On an approved account-lane run: LinkedIn searches this call spent from the account's monthly allowance.
  - `excludedCount` integer — Rows dropped by excludeProfiles/excludeWords before fetching, so a refinement is visible and never re-paid.
  - `contacts` object[], required
    - `name` string, nullable, required
    - `headline` string, nullable, required
    - `company` string, nullable, required
    - `location` string, nullable, required
    - `profileUrl` string, nullable, required
    - `completeness` string, required
    - `lane` 'linkedin' — Present when this row came from the user's connected LinkedIn account on an approved run: exact employment-record data rather than public page text.
    - `summary` string — The profile's About text, when the public page carried one.
    - `connections` integer — Connection count, when the public page carried one.
    - `imageUrl` string — The profile's display photo (from the public page's og:image), when the public page carried one. A raw LinkedIn CDN URL — may expire over time, unlike a re-hosted avatar.
    - `publicIdentifier` string — The profile's public slug, when known.
    - `networkDistance` string — Connection degree between this person and the connected account (DISTANCE_1, DISTANCE_2, DISTANCE_3). Present only on account-lane rows: it is the one datum the public lane cannot know.
    - `education` string — School name from the public page, when present.
    - `followers` integer — Public follower count from the preview page, when present. Not a login-only exact count.
    - `badge` string — Public profile badge from the preview page, when present.
    - `languages` string[] — Languages listed on the public preview page, when present.
    - `positions` object[] — Visible employers from the public preview page, when present.
      - `companyName` string, required
      - `title` string, nullable, required
      - `companyUrl` string, nullable, required
      - `startDate` object, nullable, required
        - `year` number, required
      - `endDate` object, nullable, required
        - `year` number, required
      - `isCurrent` boolean, required
    - `educations` object[] — Visible schools from the public preview page, when present.
      - `schoolName` string, required
      - `schoolUrl` string, nullable, required
      - `startDate` object, nullable, required
        - `year` number, required
      - `endDate` object, nullable, required
        - `year` number, required
    - `recentPosts` object[] — A sample of the person's own public feed posts from the preview page, when present. Search returns at most three; the saved contact keeps the fuller public list.
      - `url` string, nullable, required
      - `text` string, nullable, required
      - `likes` number, nullable, required
      - `datePublished` string, nullable, required

## Other responses

- `400` — The server cannot or will not process the request due to something that is perceived to be a client error.
- `401` — Although HTTP specifies "unauthorized", this response means "unauthenticated". Authenticate to continue. NOTE: 401 is also returned with code "linkedin_not_connected" when the caller IS authenticated but has no connected LinkedIn account — connect LinkedIn (not re-authenticate) to continue.
- `403` — The client does not have access rights to the content.
- `404` — The server cannot find the requested resource.
- `409` — The request conflicts with the current state of the server.
- `410` — The requested content has been permanently deleted from the server.
- `422` — The request was well-formed but was unable to be followed due to semantic errors.
- `429` — Rate limit exceeded. Read error.retryAfter for the wait time in seconds.
- `500` — The server encountered a situation it does not know how to handle.
- `502` — LinkedIn returned a server error or the proxy connection failed. Retry after a few seconds.
- `503` — Proxy capacity temporarily exceeded. Retry after a few seconds.

## Changes

> 11 revisions in range; 1 not diffed.

- **2026-08-28** `62bf7284843d` — 2 breaking, 20 info
  - request property `save` was restricted to a list of enum values
  - the `save` request property type/format changed from `boolean`/`` to `string`/``
  - added the new optional request property `approveLinkedInSpend`
  - added the new optional request property `excludeProfiles`
  - …18 more
- **2026-08-27** `d5f1141f87e3` — 1 breaking, 11 warning, 22 info
  - the request property `save` became required
  - added the new `pacing` enum value to the `error/quotaKind` response property for the response status `400`
  - added the new `pacing` enum value to the `error/quotaKind` response property for the response status `401`
  - added the new `pacing` enum value to the `error/quotaKind` response property for the response status `403`
  - …30 more
- **2026-08-24** `bc5eae2eb212` — 4 info
  - added the new optional request property `sentence`
  - added the optional property `askedFor` to the response with the `200` status
  - added the optional property `notFound` to the response with the `200` status
  - added the optional property `refused` to the response with the `200` status
- …earlier changes not shown

[Full history](https://skmtc.dev/berea/apis/bereach-api/changes/public/search/post.md)

---

[API](https://skmtc.dev/berea/apis/bereach-api.md) · [All operations](https://skmtc.dev/berea/apis/bereach-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/berea/bereach-api/revisions/ee1b63eb6645/schema)
