Endpoints
Filter values
List the values a filter can be set to — the lookup behind an autocomplete or a filter picker, for company/person filters and for each document category's own.
/v1/search/filters/{name}List the values a filter can take
The values one filter accepts — for autocomplete and pickers.
Geography is the one place the name here differs from the name you filter by. geo spans four levels and has no single vocabulary, so it is not a name this route accepts. Enumerate places one level at a time (country, state, city, region) and feed the answer back as geo.places[].value with that same level.
Two-level filters. industry and department are parent/child. Ask for the rows with level=parents, then one branch at a time with parent=<name> as the user expands it. department cannot be done any other way: its plain value list is all 248 values of both levels with nothing marking which is which. level=children gives the other rung, de-duplicated.
Scope: dsp.search.read
Use it when you are populating a filter picker or autocomplete, or checking that a value exists in the index before searching on it. Outcome: the canonical values the named filter accepts.
Paired with the filters object on POST /v1/search:
filterson search says which filters exist.- This route says what they can hold.
Between them you can build a filter UI without hard-coding a vocabulary that drifts the moment the index does. For what each filter means and which shape its value takes, see the filters guide.
Request#
searchstring | nullCase-insensitive substring match, applied before paging. ≥ 1 chars, ≤ 200 chars.
pageintegerdefault: 11-indexed. Page size is fixed at 20. ≥ 1.
parentstring | nullOne parent's children, for the two-level filters (industry, sub_industry, department). How a tree picker fills a branch on expand — the child list carries no parent of its own, and no substring search stands in for one. Composes with search and page. Mutually exclusive with level. ≥ 1 chars.
levelstring | nullOne rung of a two-level filter: parents or children. parents is a tree's first call, and department needs it — that filter's value list mixes both levels with nothing marking which is which, so the parents cannot be sliced out client-side. Mutually exclusive with parent. pattern ^(parents|children)$.
Path parameters
nameFilterNamerequiredWhich filter's values to list.
name is the path parameter, not in the table above. It is a closed set of 54
values:
- every
company/personfilter exceptgeo - plus the four geo levels
country,state,cityandregion
An unrecognised name is a 422 listing the accepted ones, which is why this
route has no 404. geo is refused along with them.
Geography is the one name that differs from the one you filter by
geo spans four levels and has no single vocabulary, so it is not a name this
route accepts. Enumerate places one level at a time — country, state, city,
region — and feed the answer back as geo.places[].value with that same
level. See the geo filter.
Two-level filters: level and parent
industry, sub_industry and department are parent/child, and level and
parent are how a tree picker walks them. They are mutually exclusive:
| Call | Returns |
|---|---|
?level=parents | The top rung — a tree's first call |
?parent=Software%20%26%20Technology | One parent's children, for the branch a user just expanded |
?level=children | The other rung whole, de-duplicated |
department cannot be done any other way: its plain value list is all 248
values of both levels with nothing marking which is which, so the parents cannot
be sliced out client-side. parent composes with search and page.
Response#
filterFilterNameFilter names whose values can be looked up. Every field of DiscoverFilters except geo, plus the four per-level geo names. geo is a request-side directive spanning all four levels and has no value list of its own; the four levels below are how you enumerate places, and each answer belongs in geo.places[].value with the matching level. Upstream 404s on geo for exactly this reason. A closed set rather than a validator: FastAPI turns it into a 422 that lists the accepted names and an OpenAPI enum callers can read, and neither of those is something a hand-written check gives you. Kept explicit rather than derived from DiscoverFilters.model_fields so it stays readable in the generated schema; tests/test_filters_schema.py enforces that the two never diverge.
has_morebooleanitemsarray of FilterValueItemAligned with values, for the filters that have more than a name. Empty for the rest — read it when present, fall back to values.
domainstring | nullidstring | nullnamestring | nullvaluestringmetaResponseMetaPresent on every 2xx, including the ones that are not paginated.
as_ofstringcredential"api_key" | "session"default: api_keyWhich credential produced this response. session means the dashboard playground ran it as the signed-in user rather than as an API key — so it is billed to the user, subject to interactive rate limits, and carries no integration compatibility guarantee. Additive and defaulted, so it breaks no existing client.
credits_chargednumber | nullWhat this request cost, in credits, to at most three decimals. 0 when it cost nothing: a Basic or AI search that found nothing, rows this search already paid for (a cursor sent again, or a request repeated with the same Idempotency-Key on the same UTC day), or a test key. A request without a cursor or a repeated key is a new search, so keep the first page rather than asking for it again. null on a free endpoint.
environment"live" | "test"Echoed on every response so a developer can never mistake a test-key result for a live one.
request_idstringpageintegerpage_sizeintegerAlways 20 — set upstream, not a parameter here.
totalintegerMatches for search, or the whole vocabulary without one.
valuesarray of stringEmpty for a filter with no fixed vocabulary — every range, and the free-text and entity filters. That is an answer, not a failure: those take values you supply.
Not every filter has a vocabulary
An empty values is an answer, not a failure. Three behaviours, all deliberate:
| Class | Filters | With no search |
|---|---|---|
| Bounded | technology, skills, city, state, company_keyword, sic, naics | Returns the most common values immediately |
| Free-text-backed | company, title, hiring_role, hiring_location | Returns [] until you set search |
| No vocabulary | person, employment_history, certifications, university | Always [] — use plain text entry and skip the call |
Every range filter (employee_count, revenue, founded_year, tenure,
years_worked, …) and every date filter also returns []: you supply the
bounds, there is nothing to enumerate.
Because the second class returns nothing until you type, a picker built on it needs a way to add the typed term verbatim.
items — the rows that need more than a name
items is aligned with values and is populated only where the corpus has
something to add. Today that is company alone:
GET /v1/search/filters/company?search=stripe
"values": ["stripe.com", "stripesgraphics.co.uk", …],
"items": [{ "value": "stripe.com",
"name": "stripe",
"domain": "stripe.com",
"id": "e0391674ac7f4f7ebc8d4ce27ea5d5eb" }, …]Read items when it is non-empty and fall back to values otherwise. Render the
label as name (domain) — a picker showing bare domains asks someone to choose
between stripe.com and stripesgraphics.co.uk on the strength of the domain
alone.
Document categories have their own route#
/v1/search/filters/{category}/{name}List the values a document filter can take
The values one filter accepts on one document category.
Separate from /search/filters/{name} because the two namespaces collide: employment_type is five Title-Case values on a person and ten snake_case ones on a job posting. Naming the category is what keeps them apart, and it is why name here is a free string rather than the FilterName enum — that enum mirrors the company/person set, and mirroring a second one per category is exactly the duplication this route exists to remove.
Use /search/filters/{name} for company and person.
A filter that exists but takes free values returns an empty list rather than an error — "this one accepts anything" is an answer. A 422 means the name is not a filter on that category at all.
Scope: dsp.search.read
Use this route for the seven document categories, and
/v1/search/filters/{name} for company and person. name is a free string,
spelled exactly as the search request spells it; the summary above explains why
the two namespaces are kept apart.
curl "https://api.sproutsdata.ai/v1/search/filters/{category}/{name}?search=full" \
-H "X-API-Key: $SPROUTS_API_KEY"searchstring | nullCase-insensitive substring match, applied before paging. ≥ 1 chars, ≤ 200 chars.
pageintegerdefault: 11-indexed. Page size is fixed at 20. ≥ 1.
Path parameters
categoryEntityCategoryrequiredWhich document category the filter belongs to.
namestringrequiredThe filter name, exactly as the search request spells it.
categoryEntityCategory*What* to search, and what a returned row is. One parameter carries both, because they are the same fact: a search of patent returns patents. The alternative — a source/vertical selector beside a narrower category — lets a caller ask for patents from the people index, a combination that has no meaning and would need rejecting at runtime. A closed set that cannot express the contradiction is better than a validator that catches it. Values are exactly prism's own row types, so category in the request and results[].category in the response are always the same string. company and person are the GTM index; the rest are separate corpora with their own filters, freshness and coverage. Not everything applies everywhere: see SearchRequest, which rejects the GTM-only parameters rather than ignoring them. job_opening is a document corpus like patent/news_article/etc — a posting, not an entity — but its records carry a domain (the posting company's) all the same. It is deliberately not in GTM_CATEGORIES (that set is about the entity/document distinction, and a job posting is still a document), but it *is* in DOMAIN_FILTERABLE_CATEGORIES, which exists specifically to separate those two axes.
filterstringhas_morebooleanmetaResponseMetaPresent on every 2xx, including the ones that are not paginated.
as_ofstringcredential"api_key" | "session"default: api_keyWhich credential produced this response. session means the dashboard playground ran it as the signed-in user rather than as an API key — so it is billed to the user, subject to interactive rate limits, and carries no integration compatibility guarantee. Additive and defaulted, so it breaks no existing client.
credits_chargednumber | nullWhat this request cost, in credits, to at most three decimals. 0 when it cost nothing: a Basic or AI search that found nothing, rows this search already paid for (a cursor sent again, or a request repeated with the same Idempotency-Key on the same UTC day), or a test key. A request without a cursor or a repeated key is a new search, so keep the first page rather than asking for it again. null on a free endpoint.
environment"live" | "test"Echoed on every response so a developer can never mistake a test-key result for a live one.
request_idstringpageintegerpage_sizeintegerAlways 20 — set upstream, not a parameter here.
totalintegerMatches for search, or the whole vocabulary without one.
valuesarray of stringEmpty for a filter with no fixed vocabulary — every range, and the free-text and entity filters. That is an answer, not a failure: those take values you supply.
What neither route covers#
- The filter list itself. There is no route that enumerates the filters. The
typed set is published in the OpenAPI schema as
DiscoverFilters, and the 54 lookup names as theFilterNameenum, so a generated client gets both for free. The document categories' own sets are small, fixed, and listed in the filters guide. - Sort fields. Ordering has its own catalogue —
GET /v1/sorts/{category}.
Errors#
| Status | Code | Summary |
|---|---|---|
| 401 | auth.unauthorized | No usable credential (cause deliberately unspecified) |
| 403 | auth.browser_origin | A secret key was used from a browser |
| 403 | auth.insufficient_scope | The credential does not hold the required scope |
| 422 | pagination.cursor_invalid | The cursor could not be read (e.g. a filter changed mid-pagination) |
| 422 | request.invalid | A field failed validation (extra.errors has the detail) |
| 503 | credits.unavailable | The billing service could not confirm the charge; retry with the same Idempotency-Key |
| 503 | upstream.unavailable | The retrieval engine is unreachable (not charged) |
| 504 | upstream.timeout | The retrieval engine did not answer in time (not charged) |