# Search, filters, and pagination

Use the [candidate search guide](https://api.quake.dev/documentation/guides/candidates/search-guide) for candidate relevance,
location, sorting, counts, and warnings. This guide compares resource contracts. Read each operation in
[OpenAPI JSON](https://api.quake.dev/openapi.json) before sharing one query builder across resources.

## Encode the parameter, not just the text

Candidates, clients, jobs, and contacts accept JSON-encoded `search` and `filters` strings. `sort` and supported `select`
parameters are comma-separated. Let your HTTP library encode JSON once:

```typescript
const query = new URLSearchParams({
  search: JSON.stringify({ search_text: "registered nurse" }),
  filters: JSON.stringify({ statuses: ["active"] }),
  sort: "-_score,-created_at",
  select: "id,first_name,last_name,remote_id,updated_at",
  limit: "100",
  offset: "0",
});
const response = await fetch(`https://api.quake.dev/core/candidates?${query}`, {
  headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`Candidate request failed: ${response.status}`);
const page = await response.json(); // { total, data, meta: { warnings } }
```

A plain `search=registered%20nurse` is invalid for candidates. Notes and email logs instead accept plain text in
`experimental_search`; email-log filters are separate query parameters, not a candidate-style JSON `filters` object.

## Resource differences

| Resource | Text and structured matching | List response | Page limits |
| --- | --- | --- | --- |
| Candidates | Weighted name/profile text; explicit `boolean_query`; `vector_search`; JSON filters including remote ID, owners, tags, custom attributes, skills, and location | `{ total, data, meta }` | Default 100; maximum 1,000; `offset + limit <= 10,000` |
| Clients | JSON search with name/remote-ID defaults; explicit `boolean_query`; JSON filters including status, owners, identity and custom attributes | `{ total, data }` | Default 100; maximum 1,000 |
| Jobs | JSON search, default `fields: ["title"]`; allowed fields also include description, code, remote ID; JSON relationship/status/custom-attribute filters | `{ total, data }` | Default 100; maximum 300 for list (grouped counts have a separate limit) |
| Contacts | JSON field-based text search; optional hybrid `vector_search`; JSON filters including relationships and remote ID | `{ total, data }` | Default 100; maximum 1,000 |
| Notes | Plain `experimental_search` over title/content; JSON date/tag filters; one linked `record_id` plus `record_type` pair | Array | Default 100; effective limit is capped at 1,000 (larger values are accepted but clamped); search/tag path also requires `offset + effective limit <= 10,000` |
| Email logs | Plain `experimental_search` over subject/body; address, provider, status, date and linked-record query parameters | Array of metadata and body-availability flags | Default 100; maximum 1,000; `offset + limit <= 10,000` even without text search |

Do not assume candidate Boolean syntax, location filters, `_score`, custom-attribute sorts, or field selection exist on
another resource. Contact `client_name` sorting has additional restrictions; see that operation's schema. Candidate
`search.fields` is accepted but deprecated and ignored for `search_text`, while jobs and contacts use `fields`.
Candidate grouped counts do not support `boolean_query`; list/count and grouped-count filters can differ.

## Find source records by identity

Prefer exact identity filters to relevance search when linking or reconciling imported records:

```bash
curl --get 'https://api.quake.dev/core/candidates' \
  --header 'Authorization: Bearer <token>' \
  --data-urlencode 'filters={"remote_id":"ats-candidate-42"}' \
  --data-urlencode 'select=id,remote_id,first_name,last_name' \
  --data-urlencode 'limit=100'
```

Jobs and contacts also expose `filters.remote_id`. Clients accept either `filters.remote_id` or the top-level
`remote_id` query parameter. Keep source identifiers as strings. One-word candidate text search also boosts an exact
remote-ID match, but that is ranking, not an identity-only lookup. Verify all matching rows; do not assume every resource
has a uniqueness constraint on `remote_id`.

`GET /core/notes` has no `external_id` lookup parameter. Save returned note IDs and import result mappings. Note links
normally store Quake IDs after `is_remote: true` resolution; list notes with the stored Quake `record_id` and matching
`record_type`. Email-log search supports `message_id` and `thread_id`, but those are distinct from the import upsert key
`remote_id`; the list does not expose a remote-ID query filter.

## Filter within the authorized record set

Scopes and record visibility are separate. A delegated session's owner filter narrows records it can already see; naming
another owner does not grant access. Native owner filters use dataset-user IDs, and operations that accept owner emails
resolve them within the dataset. See [delegated sessions](https://app.quake.dev/docs/openapi/delegated-openapi-sessions.md).

Use the operation's date, relationship, tag, and attribute schemas. For example, note date bounds are inclusive and a
date-only `to` includes the full UTC day. Email-log `date_from` and `date_before` use timestamps with offsets and an
exclusive upper bound. Tag values within a definition are ORed; separate definitions are ANDed where supported. Do not
substitute free text for an exact custom-attribute filter or assume null/empty values mean the same thing across filters.

## Traverse pages and handle change

Start at `offset=0`, choose a page size within the effective limit above, keep the query and ordering unchanged, and
advance by that page size. Do not request more than 1,000 notes and advance by the oversized request: clamping would
skip records. Use totals where
provided and respect the route's maximum result window. Do not infer exhaustion solely from a short page: clamping, stale
indexed records, and permission filtering can reduce the returned row count. A short or empty hydrated page can still
precede later indexed matches; total-bearing routes should account for the remaining total/window. Arrays do not include
a cursor or total, so bounded traversal and reconciliation remain best-effort rather than proof of a complete export.
Do not invent `next_cursor` or send offset as a cursor.

Use a supported explicit sort when ordering matters. Candidate queries add a deterministic ID tiebreaker, but offset
pagination is not a frozen snapshot: concurrent writes, indexing, visibility changes, and updated sort values can shift
records between requests. Deduplicate by Quake ID and reconcile updates when completeness matters. Indexed totals can
also differ from hydrated rows when records are deleted or become inaccessible. Search indexing is asynchronous;
repeating an identical read immediately after a write need not show the new record.

Email logs have an optional `search_session=new` mode that freezes ranked IDs for 15 minutes. Save the
`X-Email-Search-Session` response header and send its value as `search_session` on subsequent pages with unchanged filters
and the desired offset. Permissions are checked again on every request; this is not a snapshot of record contents or a
cross-resource export contract. An expired or invalid session needs a new search.

Inspect candidate `meta.warnings` on every page. Location fallback means the radius was not applied and distance sorting
may be omitted. A successful HTTP response alone does not prove a spatial match. Narrow queries that exceed a search
window; the API does not promise unlimited, point-in-time exports through list endpoints.
