Skip to main content
POST /v1/workspaces/{workspace_id}/people/search finds people by their attributes. It searches the same pool GET .../people serves, and returns the same paginated envelope, so you can swap one for the other without changing how you read the response.

Start from the schema

GET .../people/schema is the authoritative vocabulary. Every attribute it lists with a filter block is filterable; one without is not; one it does not list at all is refused. Copy source and key out of that block rather than guessing them, because they are not always the attribute’s own name:
Filtering on interests means "key": "tag", not "key": "interests".

The grammar

A rule is a single node. A node is either a leaf or a group, never both. A leaf tests one attribute:
A group combines nodes:
op is and, or, or not. Groups nest, so a group can hold leaves, other groups, or a mix.

Boolean semantics, exactly

A group’s op applies to its own conditions and to nothing else.
  • and matches a person satisfying every condition in that group.
  • or matches a person satisfying at least one.
  • not takes exactly one condition and matches people who do not satisfy it.
A nested group is evaluated on its own first, and its result becomes one input to its parent’s operator. There is no operator precedence to remember and nothing is ever combined implicitly: two conditions are joined by the op of the group holding them, and by nothing else.

A worked example

“Nurses in Sweden, plus teachers in Norway” is two and groups joined by an or:
The country is repeated inside both groups on purpose. or does not distribute over anything, so a country condition placed beside the two groups rather than inside them would be a third input to the or and would widen the result rather than narrow it. A Swedish teacher matches neither arm of the rule above.

Operators

Which operators are valid depends on the attribute’s type, and the schema’s filter.operators is the exact list per attribute. in and not_in take an array. between takes a two-element array of bounds, both inclusive. String comparisons are case-insensitive.

Testing whether an attribute is set

is_not_null matches people who have a value for an attribute; is_null matches people who do not. Use these rather than comparing against an empty string. An attribute nobody filled in holds no value at all, and it satisfies no ordinary comparison, not even a negated one: {"operator": "neq", "value": "nurse"} does not match a person with no occupation on file. The same applies to age, which is computed from a date of birth. A person with no birth date matches no age filter in either direction.

Rolling up interests

Interests are a tree. Filtering on a parent node also matches everyone tagged at any node beneath it, so you do not have to enumerate a subtree to select a broad interest.

Filtering into the accessibility profile

The accessibility profile is a nested object, so its filter key is a dotted path, compared against a string value with eq or neq:
To select everyone with any declared need at all, filter on accessibility_profile__has_any instead.

Limits

A rule may:
  • nest groups at most 2 levels deep,
  • hold at most 15 conditions in any one group,
  • and 25 conditions in total.
Past any of these the request is refused with 422 and detail.error_kind search_rule_too_complex, naming the offending count alongside the limit. The usual fix is in, which costs one condition instead of one per value:

When a rule is refused

Both refusals are a 422 carrying an error_kind, and they want different fixes. A malformed request body (rather than a bad rule) is the ordinary validation 422 with the field-level envelope instead. See errors for both shapes.

Saved segments

Segments live in the ish app and are not exposed on this API. A segment is a rule, so to use one, send its rule as the rule on this request. That is how the ish app applies them too.