# Lovelio API reference

Recruitment automation API. Create jobs, manage candidates, run ad-hoc actions, integrate with your tools.

Generated from the OpenAPI 3.1 document at https://lovelio.ai/api/v1/openapi.json.
API version 7.2.0. 157 paths, 234 operations.

This is the plain-markdown copy of the reference rendered at https://lovelio.ai/docs/api.

## Fetch less than all of this

| URL | What you get |
| --- | --- |
| `https://lovelio.ai/docs/api.md` | Everything: this header plus every operation in full. |
| `https://lovelio.ai/docs/api.md?index=1` | This header plus the endpoint index. No per-operation detail. |
| `https://lovelio.ai/docs/api.md?tag=Jobs` | This header plus one section in full. Comma-separate for several: `?tag=Jobs,Applications`. |
| `https://lovelio.ai/api/v1/openapi.json` | The machine-readable source this file is generated from. |

Tags: System, Accounts, Jobs, Job Ads, Candidates, Candidate Imports, Applications, Interviews, Submissions, Placements, Quotas, Marketplace, Webhooks, Batch, Activities, Documents, Tasks, Outreach, Talent Pools, Review Queue, Forms, Referees, Integrations, Chat Integrations, Analytics, Calendar, Scheduled Emails, Workflow Rules, Email Templates, Stages, Distribution, Clients, Specs, Search, Business Development.

## Base URL

Docs live on lovelio.ai. The API does not. Every workspace belongs to a region and an API key only works on its own region's host. Calling `lovelio.ai/api/v1/...` returns a JSON error naming all three hosts rather than data.

| Region | Base URL |
| --- | --- |
| Production (US (N. California)) | `https://us.lovelio.ai/api/v1` |
| Production (EU (London)) | `https://eu.lovelio.ai/api/v1` |
| Production (ANZ (Sydney)) | `https://anz.lovelio.ai/api/v1` |

A workspace's exact host is shown in Settings > API keys.

## Authentication

```http
Authorization: Bearer sk_live_...
```

API key, sent as Authorization: Bearer <key>. Three key types: sk_live_ (production), sk_test_ (development - same workspace, same data, marked as a test key; use a separate workspace if you need isolated test data), and sk_trial_ (issued at signup, expires 7 days later - swap to a live key from Settings > API keys). Keys are server-generated, shown once at creation, and scoped per permission (or admin for everything). Lovelio Connect apps authenticate with the OAuth access token from the connect flow instead (Authorization: Bearer lc_at_...) - it carries exactly the scopes the agency approved, enforced on every request. Money is gated at FIELD level, not route level: the three scopes placements:financials:read, clients:financials:read and marketplace:financials:read unlock salary, fee, contract rates, expected GP, commission percents, the client fee schedule and split-fee deal amounts. Without them those fields return null and everything else on the record still comes through - a read never 403s for want of a money scope. admin satisfies all three. Requesting any of them puts a Connect app in the elevated review tier. See /docs/agents/build-an-integration.

## Pagination and shared parameters

Every list endpoint takes these. Cursor-based: read `meta.next_cursor` from a response and pass it back as `after`.

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `limit` | query | integer, 1-100, default 25 | Rows per page. Clamped to the 1-100 range rather than rejected, so asking for 500 returns 100 - read meta.has_more and page with the cursor. Unparseable values fall back to 25. |
| `after` | query | string | Cursor for forward pagination |
| `before` | query | string | Cursor for backward pagination |
| `created_after` | query | string (date-time) | Only rows created at or after this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored. |
| `created_before` | query | string (date-time) | Only rows created at or before this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored. |

## Rate limit headers

| Header | Type | Description |
| --- | --- | --- |
| `X-RateLimit-Limit` | integer | Requests allowed per minute |
| `X-RateLimit-Remaining` | integer | Requests remaining in window |
| `X-RateLimit-Reset` | integer | Unix timestamp when window resets |

## Guides

- https://lovelio.ai/docs/agents - what an agent can drive end to end
- https://lovelio.ai/docs/agents/top-10-tasks - runnable examples for the ten most common calls
- https://lovelio.ai/docs/agents/build-an-integration - sandbox agency, test API key, live webhook testing
- https://lovelio.ai/docs/agents/error-handling - error envelope, request IDs, retries, idempotency keys
- https://lovelio.ai/docs/agents/webhooks - subscribing and verifying HMAC signatures
- https://lovelio.ai/docs/agents/sdks - single-file TypeScript and Python clients
- https://lovelio.ai/docs/api/changelog - every version, including breaking changes
- https://lovelio.ai/llms-full.txt - all of the above inlined in one fetch

## Endpoint index

Filtered to: Candidates.

### Candidates

Candidate profiles and talent database

- `GET /candidates` - List candidates
- `POST /candidates` - Create candidate
- `GET /candidates/{id}` - Get candidate
- `PATCH /candidates/{id}` - Update candidate
- `DELETE /candidates/{id}` - Delete candidate
- `DELETE /candidates/{id}/gdpr-delete` - GDPR delete candidate
- `POST /candidates/search` - Search candidates
- `GET /candidates/duplicates` - Find duplicate candidates
- `POST /candidates/merge` - Merge candidates
- `POST /candidates/from-cv` - Upload a CV and stage a candidate draft
- `GET /candidates/from-description/{review_token}` - Get a staged candidate draft
- `PATCH /candidates/from-description/{review_token}` - Patch fields on a staged candidate draft
- `POST /candidates/from-description/{review_token}/confirm` - Create a candidate from a staged draft

---

## Candidates

Candidate profiles and talent database

### GET /candidates

**List candidates**

The list includes people who exist only as client contacts (contact_only: true) - a hiring manager is a future candidate. Filter with the contact_only param to narrow either way.

Operation ID: `listCandidates`

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer, 1-100, default 25 | no | Rows per page. Clamped to the 1-100 range rather than rejected, so asking for 500 returns 100 - read meta.has_more and page with the cursor. Unparseable values fall back to 25. |
| `after` | string | no | Cursor for forward pagination |
| `email` | string | no | Exact (case-insensitive) email lookup. |
| `tag` | string | no |  |
| `location_city` | string | no | Case-insensitive substring match on location. |
| `do_not_approach` | boolean | no | Filter by the off-limits flag. Omit to list all candidates - the list includes flagged candidates by default. |
| `contact_only` | boolean | no | Role filter. true = only people who exist purely as client contacts; false = only the candidate pool. Omit to list everyone. |
| `rating` | integer, 1-5 | no | Exact star rating 1-5. |
| `min_rating` | integer, 1-5 | no | Minimum star rating 1-5 ("4 and up" = 4). Unrated candidates never match. |
| `created_after` | string (date-time) | no | Only rows created at or after this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored. |
| `created_before` | string (date-time) | no | Only rows created at or before this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Paginated candidates | { success, data: array of Candidate, meta } |

### POST /candidates

**Create candidate**

Operation ID: `createCandidate`

**Request body** (`application/json`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | Full name. Alternatively pass first_name + last_name. |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `email` | string | yes |  |
| `phone` | string \| null | no |  |
| `linkedin_url` | string \| null | no |  |
| `location` | object | no |  |
| `location.city` | string | no |  |
| `skills` | array of string | no |  |
| `resume_url` | string \| null | no | Public CV URL. Queues an async parse (employment history, skills, education). Sending one draws on the account CV allowance: 5,000 per rolling 30 days, shared with bulk import and /candidates/from-cv, refused with 403 QUOTA_EXCEEDED past it. Creating a candidate WITHOUT a resume_url is uncapped, and applications people send you never count. |
| `rating` | integer, 1-5 \| null | no | The agency's 1-5 star rating. |
| `tags` | array of string | no | Flat labels to put on the candidate. Deduplicated case-insensitively, so "Python" and "python" are one tag. Unknown labels are created. Max 40 characters each (longer is truncated) and max 20 tags per candidate counted after deduplication - more than 20 is a validation error, never a silent trim. |
| `preferred_roles` | array of string \| null | no | Roles the candidate wants next. |
| `preferred_seniority` | string (`intern`, `junior`, `mid`, `senior`, `staff`, `lead`, `principal`, `head`, `director`, `vp`, `c_suite`) \| null | no |  |
| `work_types` | array of string (`permanent`, `contract`, `temp`, `part_time`) \| null | no |  |
| `open_to_relocation` | boolean \| null | no |  |
| `available_from` | string (date) \| null | no |  |
| `preferred_locations` | array of string \| null | no | Places the candidate wants to work; gazetteer-resolved asynchronously. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Created | { success, data: Candidate } |
| `403` | QUOTA_EXCEEDED - a resume_url was sent and the account has used its 5,000-CV rolling 30-day allowance. |  |
| `409` | A live candidate with this email already exists in this company (emails are unique case-insensitively). |  |

### GET /candidates/{id}

**Get candidate**

Operation ID: `getCandidate`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Candidate details | { success, data: Candidate } |

### PATCH /candidates/{id}

**Update candidate**

Operation ID: `updateCandidate`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Request body** (`application/json`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `email` | string \| null | no |  |
| `phone` | string \| null | no |  |
| `linkedin_url` | string \| null | no |  |
| `location` | object | no |  |
| `location.city` | string | no |  |
| `skills` | array of string | no |  |
| `tags` | array of string | no | Flat labels on the candidate. Replaces the FULL set: an empty array clears every tag, omitting the field leaves them alone. Deduplicated case-insensitively, so "Python" and "python" are one tag. Unknown labels are created. Max 40 characters each (longer is truncated) and max 20 tags per candidate counted after deduplication - more than 20 is a validation error, never a silent trim. |
| `current_title` | string \| null | no | Also accepted as current_job_title. |
| `current_company` | string \| null | no | Also accepted as current_employer. |
| `do_not_approach` | boolean | no | Off-limits flag. Flagged candidates are excluded from search, matching and suggestions. |
| `open_to_work` | boolean \| null | no | Open-to-work flag. Null clears it back to never-captured. |
| `rating` | integer, 1-5 \| null | no | The agency's 1-5 star rating. Null clears it. |
| `salary_expectation` | string \| null | no | Raw salary expectation ("145k + super", "850/day"). The normalized annual figure, amount, period, and currency derive automatically. |
| `notice_period` | string \| null | no | Raw notice period ("4 weeks"). notice_period_days derives automatically. |
| `work_rights` | string \| null | no | Raw right-to-work statement ("citizen", "482 visa"). work_rights_status derives automatically. |
| `preferred_roles` | array of string \| null | no | Roles the candidate wants next. Null clears. |
| `preferred_seniority` | string (`intern`, `junior`, `mid`, `senior`, `staff`, `lead`, `principal`, `head`, `director`, `vp`, `c_suite`) \| null | no | Seniority level the candidate wants. Free text like "Head of" normalizes automatically. |
| `work_types` | array of string (`permanent`, `contract`, `temp`, `part_time`) \| null | no | Work types the candidate is open to. Variants like "perm" or "full-time" normalize automatically. |
| `open_to_relocation` | boolean \| null | no |  |
| `available_from` | string (date) \| null | no | Stated availability date (YYYY-MM-DD). Null clears. |
| `preferred_locations` | array of string \| null | no | Places the candidate wants to work. Replaces the FULL set on every write; each entry is gazetteer-resolved asynchronously for search. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Updated | { success, data: Candidate } |

### DELETE /candidates/{id}

**Delete candidate**

Soft deletes a candidate. The record is hidden from all queries but not permanently removed. For permanent GDPR erasure, use the /candidates/{id}/gdpr-delete endpoint instead. Requires candidates:write scope.

Operation ID: `deleteCandidate`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Deleted | { deleted, id } |
| `404` | Candidate not found |  |

### DELETE /candidates/{id}/gdpr-delete

**GDPR delete candidate**

Permanently deletes all candidate data including applications, interviews, assessments, and stored CVs. Irreversible.

Operation ID: `gdprDeleteCandidate`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Deleted | { success, deleted, id } |

### POST /candidates/search

**Search candidates**

Natural language candidate search on the same engine as the in-app search bar: the query compiles into hard requirements and preferences (returned as `interpretation.facets`), hard requirements filter in the database, and a deterministic feature score ranks the rest. `total_count` is the real database count of candidates matching every hard requirement. Pagination walks the ranked pool (`ranked_pool_size`); when the true total exceeds it, a note in `notes` says so. `notes` also carries anything the engine could not do properly (a degraded plan, a truncated scan) - an empty array means everything ran as interpreted. A query naming a company may also return `mentioned_in_cv`: a separate, separately-counted tier of people whose CV names that company while their employment history does not. Those people did not match, and the two counts are never added together.

Operation ID: `searchCandidates`

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | yes | What you are looking for, in plain language. Locations, salaries, employers and time windows belong in the query itself ("sales managers in Sydney on under 150k"). |
| `limit` | integer, 1-100, default 25 | no | Results per page. |
| `page` | integer, min 1, default 1 | no | 1-based page into the ranked pool. The ranking is deterministic, so page N is always the same slice for the same query. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Ranked matches | { success, data: CandidateSearchResult, meta } |
| `422` | query is required, or limit/page out of range. |  |
| `500` | The search compiler is temporarily unavailable. The API never degrades to a different matcher - retry. |  |

### GET /candidates/duplicates

**Find duplicate candidates**

Operation ID: `findDuplicates`

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Duplicate pairs | { success, data: array of object } |

### POST /candidates/merge

**Merge candidates**

Operation ID: `mergeCandidates`

**Request body** (`application/json`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `keep_id` | string | yes |  |
| `merge_id` | string | yes |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Merged | { success, data: Candidate } |

### POST /candidates/from-cv

**Upload a CV and stage a candidate draft**

Upload a base64-encoded PDF or DOCX. Lovelio runs a Phase 1 regex parse plus a Phase 2 AI parse (in parallel) and stages the result in candidate_drafts. Returns a `cdr_` review token that every follow-up endpoint under /candidates/from-description/{review_token} consumes. Mandatory fields (name, email) that are missing are reported in `gaps.mandatory`; confirm will refuse until they are filled via PATCH.

Each call draws on the account CV allowance: 5,000 per rolling 30 days, shared with bulk import and POST /candidates with a resume_url, refused with 403 QUOTA_EXCEEDED past it. Applications people send you never count towards it.

Operation ID: `stageCandidateFromCv`

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `file_base64` | string | yes | Raw base64-encoded CV bytes (data-URL prefix optional). |
| `mime_type` | string | no | application/pdf or application/vnd.openxmlformats-officedocument.wordprocessingml.document. Defaults to application/pdf. |
| `source` | string (`slack`, `mcp`, `dashboard`, `api`) | no | Provenance tag stored on the draft row. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Draft staged | { success, data: object } |
| `400` | file_base64 is missing, empty, invalid base64, or larger than 10 MB. Also returned when the file is password-protected or could not be text-extracted. |  |
| `403` | QUOTA_EXCEEDED - the account has used its 5,000-CV rolling 30-day allowance. |  |

### GET /candidates/from-description/{review_token}

**Get a staged candidate draft**

Read current state of a staged candidate draft. Use this to poll gaps after editing.

Operation ID: `getCandidateDraft`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `review_token` | string | yes |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Draft state | { success, data: object } |
| `400` | Draft not found or already deleted. |  |

### PATCH /candidates/from-description/{review_token}

**Patch fields on a staged candidate draft**

Update one or more fields on an in-flight candidate draft (name, email, phone, linkedin_url, location, current_title, current_company). Recomputes gaps.

Operation ID: `updateCandidateDraft`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `review_token` | string | yes |  |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string \| null | no |  |
| `email` | string \| null | no |  |
| `phone` | string \| null | no |  |
| `linkedin_url` | string \| null | no |  |
| `location` | string \| null | no |  |
| `current_title` | string \| null | no |  |
| `current_company` | string \| null | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Updated draft + remaining gaps | { success, data: object } |
| `400` | Draft missing, expired, already confirmed, or cancelled. |  |

### POST /candidates/from-description/{review_token}/confirm

**Create a candidate from a staged draft**

Promote a staged candidate draft by dispatching through the existing create_candidate action handler. Refuses with 409 `{ error: { type: "draft_has_gaps", gaps: { mandatory, warn } } }` when mandatory fields (name, email) are empty. There is no `force` bypass - partners who need to create a candidate without an email use POST /candidates directly.

Operation ID: `confirmCandidateDraft`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `review_token` | string | yes |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Candidate created. | { success, data: object } |
| `400` | Draft missing, expired, or already confirmed. |  |
| `409` | Draft has gaps - name or email is empty. Fill them via PATCH /candidates/from-description/{review_token} then retry. |  |

## Schemas

Objects referenced by the operations above.

### Candidate

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `account_id` | string | no |  |
| `first_name` | string | no |  |
| `last_name` | string | no |  |
| `name` | string | no |  |
| `email` | string (email) \| null | no |  |
| `phone` | string \| null | no |  |
| `location` | object \| null | no | Where the candidate lives, coarsened to suburb (or city) and state - "Balmain, NSW". Never a street address, whatever precision the record was captured at. |
| `location.city` | string | no |  |
| `linkedin_url` | string \| null | no |  |
| `current_title` | string \| null | no | Current job title. Auto-populated from parsed CV; editable on the candidate record. Indexed and searchable. PATCH accepts current_title or current_job_title interchangeably. |
| `current_company` | string \| null | no | Current employer. Auto-populated from parsed CV; editable on the candidate record. Indexed and searchable. PATCH accepts current_company or current_employer interchangeably. |
| `years_experience` | integer \| null | no | Estimated years of experience from work history. |
| `skills` | array of string | no |  |
| `education` | array of object | no |  |
| `education[].degree` | string \| null | no |  |
| `education[].institution` | string | no |  |
| `education[].year` | integer \| null | no |  |
| `parsed_profile` | ParsedProfile | no |  |
| `cv_url` | string \| null | no | URL to the uploaded CV document. |
| `status` | string | no |  |
| `availability` | string (`unknown`, `available`, `passive`, `not_available`) | no |  |
| `source` | string \| null | no | How the candidate entered the system, e.g. 'bulk_import' for candidates created by a CV import. |
| `tags` | array of string | no | Flat labels the agency put on this candidate, alphabetical. Tags are deduplicated case-insensitively, so the label comes back with the spelling the tag was first created with. |
| `do_not_approach` | boolean | no | Off-limits flag. Flagged candidates are excluded from search, matching and suggestions. |
| `contact_only` | boolean | no | True while this person exists only as a client contact: searchable, but not yet in a candidate process. Flips false the moment they enter one (application, talent pool, or a deliberate candidate create). |
| `rating` | integer, 1-5 \| null | no | The agency's 1-5 star rating on the candidate. Null = not yet rated. |
| `open_to_work` | boolean \| null | no | Open-to-work flag, from a LinkedIn sighting or a consultant's toggle. Null = never captured either way. |
| `salary_expectation` | string \| null | no | Captured salary expectation, verbatim ("145k + super"). Null = never captured. |
| `salary_expectation_annual` | number \| null | no | Normalized annual figure derived from salary_expectation, when parseable. |
| `salary_currency` | string \| null | no | Currency code when the expectation stated one (AUD, USD, GBP, EUR, NZD). |
| `notice_period` | string \| null | no | Captured notice period, verbatim ("4 weeks"). |
| `notice_period_days` | integer \| null | no | Normalized days derived from notice_period (0 = immediately available). |
| `work_rights` | string \| null | no | Captured right-to-work statement, verbatim ("Australian citizen"). |
| `work_rights_status` | string (`citizen`, `permanent_resident`, `visa_holder`, `requires_sponsorship`) \| null | no | Normalized status derived from work_rights. |
| `salary_expectation_amount` | number \| null | no | Amount in the candidate's own period derived from salary_expectation (850 for "850/day"). |
| `salary_expectation_period` | string (`year`, `day`, `hour`) \| null | no | Period derived from salary_expectation. Day and hour rates never fabricate an annual figure. |
| `preferred_roles` | array of string \| null | no | Roles the candidate said they want next. Null = never captured. |
| `preferred_seniority` | string (`intern`, `junior`, `mid`, `senior`, `staff`, `lead`, `principal`, `head`, `director`, `vp`, `c_suite`) \| null | no | Seniority level the candidate wants. |
| `work_types` | array of string (`permanent`, `contract`, `temp`, `part_time`) \| null | no | Work types the candidate is open to. |
| `open_to_relocation` | boolean \| null | no | Whether the candidate said they would relocate. Null = never captured. |
| `available_from` | string (date) \| null | no | Stated availability date. Complements notice_period in availability search. |
| `preferred_locations` | array of string \| null | no | Places the candidate said they want to work. Gazetteer-resolved for search. |
| `applications` | array of object | no | Summary of all applications for this candidate. |
| `applications[].job_id` | string \| null | no |  |
| `applications[].job_title` | string \| null | no |  |
| `applications[].application_id` | string | no |  |
| `applications[].status` | string | no |  |
| `created_at` | string (date-time) | no |  |
| `updated_at` | string (date-time) | no |  |
| `enriched_at` | string (date-time) \| null | no |  |

### CandidateSearchResult

What the faceted search core returns for a candidate query. Identical for POST /candidates/search and POST /search with entity_type=candidates - the two doors run the same function.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `entity_type` | string (`candidates`) | no | Present on POST /search responses so a client can branch; absent from POST /candidates/search, which only ever searches candidates. |
| `interpretation` | object | no | How the engine read the query. |
| `interpretation.intent` | string \| null | no | The soft ask used for semantic ranking. |
| `interpretation.facets` | array of object | no |  |
| `interpretation.facets[].label` | string | no | Plain-English label, as shown on the in-app chips. |
| `interpretation.facets[].kind` | string | no | What the facet checks (role, skill, location, employer, ...). "unsupported" means the engine understood the ask but cannot filter on it yet. |
| `interpretation.facets[].strength` | string (`must`, `nice`, `exclude`) | no | must filters, nice ranks, exclude removes. |
| `total_count` | integer | no | Real database count of candidates matching every hard requirement. |
| `ranked_pool_size` | integer | no | How many of those were ranked - the depth pagination can reach. |
| `page` | integer | no |  |
| `page_size` | integer | no |  |
| `has_more` | boolean | no |  |
| `notes` | array of object | no | Anything the engine could not do properly (a degraded plan, a truncated scan). An empty array means everything ran as interpreted. |
| `notes[].code` | string | no |  |
| `notes[].message` | string | no |  |
| `results` | array of object | no |  |
| `results[].id` | string | no | External candidate id (cnd_...). |
| `results[].name` | string \| null | no |  |
| `results[].email` | string \| null | no |  |
| `results[].location` | string \| null | no |  |
| `results[].current_job_title` | string \| null | no |  |
| `results[].current_employer` | string \| null | no |  |
| `results[].match_score` | integer | no | The engine's deterministic feature score, 0-100. Decides the order. |
| `results[].band` | string (`strong`, `good`, `partial`) | no | Requirement-coverage band. |
| `results[].coverage` | object | no | Requirements this candidate evidences, out of the total asked. |
| `results[].coverage.matched` | integer | no |  |
| `results[].coverage.total` | integer | no |  |
| `mentioned_in_cv` | object | no | People who did NOT match, kept separate. Present only when the query names a company and somebody's CV mentions it while their employment history does not. Never add this `total_count` to the top-level one: measured on a 9,378-candidate book, roughly half of these people did work at the company and it was never captured, and roughly half never worked there at all ("founded by ex-McKinsey partners", "in competition with Bain"). They carry no match_score or band because there is nothing to score. Only ever produced for employer requirements - a company name is a rare distinctive token in prose, a job title is not. |
| `mentioned_in_cv.total_count` | integer | no | Real database count of the whole tier, which can exceed the rows returned. |
| `mentioned_in_cv.facet_labels` | array of string | no | Which interpretation facets these people reached by a CV mention rather than by employment history. |
| `mentioned_in_cv.results` | array of object | no |  |
| `mentioned_in_cv.results[].id` | string | no |  |
| `mentioned_in_cv.results[].name` | string \| null | no |  |
| `mentioned_in_cv.results[].email` | string \| null | no |  |
| `mentioned_in_cv.results[].location` | string \| null | no |  |
| `mentioned_in_cv.results[].current_job_title` | string \| null | no |  |
| `mentioned_in_cv.results[].current_employer` | string \| null | no |  |

### Meta

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `request_id` | string | no |  |
| `count` | integer | no |  |
| `has_more` | boolean | no |  |
| `next_cursor` | string \| null | no |  |
| `prev_cursor` | string \| null | no |  |
