profiles

List Public Profiles

The directory: everybody who has published a profile, newest first.

It is also the candidate search

A company account looking for people uses this endpoint with filters on. There is deliberately no second "search" endpoint: a search IS the directory with a WHERE clause, and a parallel endpoint would be a second serialiser, a second cache policy and a second place for the contact gate to be got wrong. Every filter below is AND-ed with the others and every one is skipped entirely when absent, so the unfiltered call is byte-for-byte the response it always was.

q free text over display_name, headline, company and role -- the four fields that answer "who is this and what do they do". OR-ed among themselves, because a recruiter typing "stripe" does not know or care whether it is somebody's employer or their headline. NOT over bio: an unindexed LIKE over a 2,000 character column is the scan that teaches people to stop using the search. skill REPEATABLE, and AND-ed. See the block on the EXISTS below. open_to_work the master switch on the profile, honoured as a switch. seniority against seeking_seniority, on the shared rung vocabulary. remote against seeking_remote, on the shared mode vocabulary. comp_max an affordability ceiling. See the block on NULL below -- it is the one filter here that had a real decision in it. location case-insensitive contains over seeking_locations OR location: where they would go, or where they already are. One parameter rather than two, because a recruiter asking for Berlin means "can this person work in Berlin" and does not want to run the query twice to find out. based_in case-insensitive contains over location ALONE: where somebody actually is. See the block below on why this is not location with a flag.

Filtering does not widen the serialiser. _directory_profile is an allowlist and it is unchanged: it is possible to filter on seeking_comp_min and on open_to_work and to receive neither of them back, and that is correct rather than an oversight. A filter is a question about the row; the allowlist is the answer the row has agreed to give. The one that would actually hurt somebody is the contact bundle, which is not in this payload in any mode for any viewer -- so no combination of filters can turn this into the bulk export _directory_profile exists to prevent.

Why there is no viewer

Unlike every other public read in this codebase, this takes no OptionalUserId. Nothing in the response depends on who is asking -- no owned, no following, no contact fields at any visibility -- so resolving a viewer would buy nothing and would cost the shared cache, which is the whole reason _public_read_cache has a private branch. A directory is the one page here that is genuinely the same for everyone, and it is allowed to say so.

That survives the filters, and it is worth saying why: a filtered response is still the same for every caller who sends the same query string, because a query string is part of a cache key and a viewer is not. So the search is shared-cacheable exactly like the unfiltered directory, with no Vary and no private branch. Resolving a viewer here -- to sort by "people like you", say -- would silently cost that, on the surface most likely to be polled.

Why newest first, and not by followers

Recency is honest with one member and with ten thousand. Ordering by follower total sounds better and is not available cheaply: followers live on agents, so a popularity sort needs the aggregate in the ORDER BY, which means either a correlated subquery per row or a GROUP BY join that cannot be keyset-paginated on a value that changes under the reader. The gallery can sort by popularity because agents.follower_count is a denormalised column ON the row being sorted; there is no equivalent column here, and adding one would mean keeping a second counter in step with the first.

So: keyset on (created_at DESC, handle DESC), which is a total order and pages correctly even when a hundred profiles share a timestamp -- which they will, the day a backfill creates them.

That ordering is chosen independently of the filters, which is what makes paging a filtered search work at all: every predicate below narrows the set without touching the sort key, so the cursor still names a position in a total order over the filtered rows. A relevance sort would break that -- a score computed from q is not a stored column, so a cursor could not name a position in it, and the endpoint would have to fall back to offset paging and the skipping-and-repeating it exists to avoid.

Query budget

Three statements, constant in the page size AND constant in the number of filters: the page of profiles, one batched audience aggregate, one batched tag read. Every filter is a predicate inside statement one, including the skill ones -- see the EXISTS block below for why a join was refused. The two batches are the whole reason those helpers exist; per-row versions of either are the N+1 this endpoint would otherwise ship on the one page designed to be crawled.

get/api/public/profiles

Request

  • Base URL: https://api.parlel.com (the document declares no server; this is the origin it was published from)
  • URL: https://api.parlel.com/api/public/profiles
  • Auth: none declared

Query parameters

limitinteger
cursorstring nullable
qstring nullable
skillstring[] nullable
open_to_workboolean nullable
seniority'intern' | 'junior' | 'mid' | 'senior' | 'staff' | 'principal' | 'exec' nullable
remote'onsite' | 'hybrid' | 'remote' nullable
comp_maxinteger nullable
locationstring nullable
based_instring nullable

Response

Successful Response

object required

Changes

No recorded changes to this operation across all 1 revision of this API.