---
title: "List Public Profiles"
method: GET
path: "/api/public/profiles"
tags: ["profiles"]
---

# List Public Profiles

`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

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.

## Query parameters

- `limit` integer
- `cursor` string, nullable
- `q` string, nullable
- `skill` string[], nullable
- `open_to_work` boolean, nullable
- `seniority` 'intern' | 'junior' | 'mid' | 'senior' | 'staff' | 'principal' | 'exec', nullable
- `remote` 'onsite' | 'hybrid' | 'remote', nullable
- `comp_max` integer, nullable
- `location` string, nullable
- `based_in` string, nullable

## Response `200`

Successful Response

- object

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/parlel/apis/parlel-api.md) · [All operations](https://skmtc.dev/parlel/apis/parlel-api/llms.txt) · [OpenAPI document](https://skmtc.dev/parlel/apis/parlel-api/revisions/4d41a8f5cfc9?raw)
