API reference

Query Spec

One grammar searches all three datasets. It is a small boolean tree of field conditions, and the same request shape works against people, jobs, and companies.

The shape of a query

Every structured search sends a where tree and an optional page size. The tree is built from two things: composers, which combine conditions, and leaves, which test one field.

{
  "where": {"all": [
    {"field": "<name>", "<operator>": <value>}
  ]},
  "size": 25
}

A leaf names one field and applies one operator to it. Everything else in the grammar is a way of arranging leaves.

A query must carry at least one condition. An empty tree is refused with At least one search condition is required rather than returning the whole corpus.

Composing conditions

Composer
all
Takes
An array of nodes
Matches when
Every node matches. This is the usual outer wrapper
Composer
any
Takes
An array of nodes
Matches when
At least one node matches
Composer
not
Takes
A single node, or an array
Matches when
The node does not match. An array negates all of it

Composers nest, so an all can contain an any that contains a not. This example asks for a company in the United States that is either mid-sized or well funded, and is not publicly listed.

POST/v1/companies/query
{
  "where": {"all": [
    {"field": "hq_country", "match": "United States"},
    {"any": [
      {"field": "employees_count", "gte": 200},
      {"field": "last_funding_round_amount_raised", "gte": 50000000}
    ]},
    {"not": {"field": "type", "eq": "Public Company"}}
  ]},
  "size": 50
}

Operators

Operator
eq
Takes
One value
Means
Exact match on the whole value
Operator
in
Takes
A non-empty array
Means
Matches any one of the listed values
Operator
match
Takes
Text
Means
Analyzed text match. Every word must be present
Operator
gte
Takes
A number, or a date on a date field
Means
At or above
Operator
lte
Takes
A number, or a date on a date field
Means
At or below
Operator
exists
Takes
true
Means
The field is present on the record

One operator per leaf

A leaf carries exactly one operator. A bounded range is therefore an all of two leaves over the same field, not gte and lte in one node. This is the mistake nearly every caller makes first, and it is refused with Each field condition takes exactly one operatorrather than silently applying one half of the range.

employees_count below is a company field. Field names are not shared between the three datasets, so a leaf only means anything against the endpoint that publishes its field.

// Wrong. Two operators in one leaf.
{"field": "employees_count", "gte": 50, "lte": 500}

// Right. Two leaves, combined.
{"all": [
  {"field": "employees_count", "gte": 50},
  {"field": "employees_count", "lte": 500}
]}

Operators are also matched to the kind of field. Numeric and date fields accept gte, lte, and exists only; text and category fields accept eq, in, match, and exists. Asking a numeric field for eq, or passing "50" as a string where a number is expected, is refused rather than reinterpreted. Each dataset page lists the operators its fields accept.

exists has one form. To ask for records where a field is absent, wrap the leaf: {"not": {"field": "salary_min", "exists": true}}.

Exact fields are case-sensitive

A text field matches text, where every word in your value has to appear. An exact field compares the whole stored value, character for character, so "director" matches nothing where "Director" matches.

The one mistake that raises no error

Every other error in this grammar is refused before the query runs. Wrong case is not an error: the query is valid, it executes, and it matches zero records. A result of zero reads as "nobody like that exists" rather than "wrong case", which is why it is worth naming here rather than leaving to be discovered.

Several fields draw from a fixed list, and the lists live with the fields rather than here: People, Jobs and Companies each carry the values for their own. One copy per value, on the page you were already reading to find the field.

Several of those values contain commas — Oil, Gas, and Mining is one value, not three — which is the other half of why they are shown in a table there rather than run together in a sentence here.

Asking a yes/no field for no

true means the same thing on all six of them. false does not, because a field that is only written when it is true has no false to match: it is absent instead, and absent is not a value you can ask for with eq.

Field
is_working, is_decision_maker, is_current
How to ask for no
false. Both answers are stored, so both can be asked for
Field
is_studying, is_b2b
How to ask for no
not, wrapping the positive: the field is absent rather than false, so eq false matches nothing
Field
application_active
How to ask for no
Nothing to ask. Every job in the searchable index is active, so this condition removes no results today
POST/v1/companies/query
// Matches nothing: there is no stored false to match.
{"where": {"field": "is_b2b", "eq": false}}

// The companies that are not B2B.
{"where": {"not": {"field": "is_b2b", "eq": true}}}

Why this is worth a section

A query built the first way is valid, costs nothing, and returns zero. Like wrong case, it reads as an answer about the data rather than about the query. The counts behind this table were read off the indexes the search actually uses, so they describe what is stored rather than what the field names imply.

Relative dates

Date fields take an absolute YYYY-MM-DD or a relative value that the server resolves to an absolute day before the query runs. That means a saved query keeps meaning "the last 30 days" instead of freezing on the day it was written.

Form
now
Example
now
Maximum offset
Not applicable
Form
now-<n>d
Example
now-30d
Maximum offset
7300 days
Form
now-<n>m
Example
now-6m
Maximum offset
240 months
Form
now-<n>y
Example
now-5y
Maximum offset
20 years
POST/v1/jobs/query
{"where": {"all": [
  {"field": "posted", "gte": "now-30d"},
  {"field": "employment_type", "match": "Contract"}
]}}

A relative value on a field that is not a date is refused. The date fields are marked as such in each dataset's field table.

Same-record scopes

A profile holds many jobs and many degrees. Without a scope, two conditions can be satisfied by two different records: one job at Google, and a separate job as a director somewhere else. A scope forces both to land on the same record.

// "Was a director at Google": both conditions, one job.
{"where": {"has_experience": {"all": [
  {"field": "company_name", "match": "Google"},
  {"field": "level", "eq": "Director"}
]}}}

// "Worked at Google, and was a director somewhere":
// two conditions, possibly two different jobs.
{"where": {"all": [
  {"field": "company_name", "match": "Google"},
  {"field": "level", "eq": "Director"}
]}}
Scope
has_experience
Groups
One job: employer, title, level, dates, and workplace location
Available on
People
Scope
has_education
Groups
One degree: institution, major, level, and years
Available on
People
Scope
has_language
Groups
One language entry
Available on
People

People are the only entity with scopes. A job document is one job and a company document is one company, so there is nothing to group. Scopes also do not nest inside one another, and each scope accepts only the fields that belong to it.

Paging with after and next

A search response includes next while more results remain. Pass that value back as after, with the same where tree, to get the following page. On the last page next is absent rather than empty, so its absence is how you know you are done.

POST/v1/jobs/query
// Page 1 response
{"data": {"job_ids": ["Jb71xKcAoP"], "next": "WzE3MjQwMDAwMDAsIkpiNzF4S2NBb1AiXQ"}}

// Page 2 request
{"where": {"all": [{"field": "title", "match": "data engineer"}]},
 "size": 100,
 "after": "WzE3MjQwMDAwMDAsIkpiNzF4S2NBb1AiXQ"}

Pages are not a snapshot

There is no point-in-time. The cursor carries the sort position of the last result and nothing else, so the server stores no state and a cursor does not expire. The cost of that is consistency between pages: a record indexed while you are paging can appear on a later page, move, or be missed entirely. If you need an exact set, narrow the query rather than paging deeper.

Limits

Limit
Nesting depth
Value
6
What happens past it
The query is refused before it runs
Limit
Conditions per query
Value
64
What happens past it
The query is refused before it runs
Limit
Results per page
Value
100 by default, 10,000 maximum
What happens past it
Values above the maximum are rejected
Limit
IDs per detail request
Value
100
What happens past it
Split the IDs across several calls

A field name that is not in the dataset's list is refused. The three datasets do not share one vocabulary, so check the name against the page for the endpoint you are calling. A translation failure costs nothing: the query is checked before any Credit is charged.

Where the field names live

The grammar is shared; the vocabulary is not. Each dataset publishes its own field list with the operators each field accepts, and a name from one dataset is not valid in another.

What a search can filter on and what a detail response can return are two different lists. A field may be returnable without being queryable, and the query vocabulary is deliberately the smaller of the two.