# 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: Candidate Imports.

### Candidate Imports

Bulk CV import - migrate thousands of CVs into the talent bank

- `POST /candidate-imports` - Start a bulk CV import
- `GET /candidate-imports/{id}` - Get import report
- `POST /candidate-imports/{id}/files` - Request upload slots
- `POST /candidate-imports/{id}/finalize` - Confirm uploads

---

## Candidate Imports

Bulk CV import - migrate thousands of CVs into the talent bank

### POST /candidate-imports

**Start a bulk CV import**

Bulk-import CVs (PDF, DOC or DOCX, 10 MB each). Declare how many files you will send, then request upload slots in chunks via POST /candidate-imports/{id}/files, PUT each file body to its upload_url, and confirm with POST /candidate-imports/{id}/finalize. Each file is parsed into a candidate in the background: candidates are matched by email (existing ones are updated, never duplicated), unreadable files are skipped and reported, and the import always completes. Imported candidates land in the talent bank without applications, so imports never trigger AI assessments.

Limits: 5,000 files per import, and a 5,000-CV allowance per account per rolling 30 days shared with POST /candidates (when it carries a resume_url) and POST /candidates/from-cv. Both the import and the slot request refuse with 403 QUOTA_EXCEEDED past it. Applications people send you never count towards the allowance. This endpoint is for topping up your database, not for moving off another ATS: a full migration brings across notes, jobs, placements and history that this door does not accept, and Lovelio runs it for you - email support@lovelio.ai.

On imports of 200 files or more the deeper parse (skills, work history, education, role and employer) is queued and can take up to a day; names and contact details are written immediately either way. Requires candidates:write.

Operation ID: `createCandidateImport`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `file_count` | integer, 1-5000 | yes | How many files this import will send. |
| `match_pools` | boolean, default true | no | Match imported candidates against talent pools with AI matching on as they land. Defaults to true; pass false to import without pool matching. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Import created | { success, data: object } |
| `403` | QUOTA_EXCEEDED - this account has used its 5,000-file rolling 30-day import allowance |  |
| `413` | PAYLOAD_TOO_LARGE - file_count is above 5,000 |  |

### GET /candidate-imports/{id}

**Get import report**

Live progress while processing; the full report once complete: per-outcome counts, failed files with human-readable reasons, and role_matches - how the imported candidates compare with your open roles (embedding similarity, 0-100; no assessments are run).

Operation ID: `getCandidateImport`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Import report | { success, data: CandidateImport } |
| `404` | Import not found |  |

### POST /candidate-imports/{id}/files

**Request upload slots**

Returns one signed upload slot per file (up to 25 per call). PUT the raw file body to each upload_url with any HTTP client - files never travel through this API. Only pdf, doc and docx filenames receive slots. An import cannot accept more files than its declared file_count, and slots stop being issued once the account reaches its 5,000-file rolling 30-day allowance (403 QUOTA_EXCEEDED).

Operation ID: `addCandidateImportFiles`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `files` | array of object | yes |  |
| `files[].filename` | string | yes |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Upload slots | { success, data: object } |
| `403` | QUOTA_EXCEEDED - this account has used its 5,000-file rolling 30-day import allowance |  |
| `409` | Import is no longer accepting files, or file_count would be exceeded |  |

### POST /candidate-imports/{id}/finalize

**Confirm uploads**

Confirm which items were uploaded; each confirmed item is queued for background parsing. Pass complete: true on the last call to close the upload phase - processing then finishes on its own and GET /candidate-imports/{id} becomes the report. Items signed but never confirmed are failed by a sweep after 2 hours, so the import always completes.

Operation ID: `finalizeCandidateImport`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `uploaded_item_ids` | array of string | no |  |
| `complete` | boolean | no | Set true on the final call to close the upload phase. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Confirmed | { success, data: object } |

## Schemas

Objects referenced by the operations above.

### CandidateImport

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `status` | string (`uploading`, `processing`, `complete`, `cancelled`) | no |  |
| `file_count` | integer | no |  |
| `created_at` | string (date-time) \| null | no |  |
| `completed_at` | string (date-time) \| null | no |  |
| `counts` | object | no |  |
| `counts.pending` | integer | no |  |
| `counts.processing` | integer | no |  |
| `counts.imported` | integer | no | New candidates created. |
| `counts.merged` | integer | no | Existing candidates updated (matched by email). |
| `counts.failed` | integer | no | Files that could not be read. |
| `failures` | array of object | no |  |
| `failures[].filename` | string | no |  |
| `failures[].reason` | string | no | Human-readable reason the file was skipped. |
| `role_matches` | array of object \| null | no | How the imported candidates compare with the open roles - embedding similarity only, no assessments. Null until matching has run. |
| `role_matches[].job_id` | string | no |  |
| `role_matches[].job_title` | string | no |  |
| `role_matches[].strong_count` | integer | no |  |
| `role_matches[].matches` | array of object | no |  |
| `role_matches[].matches[].candidate_id` | string | no |  |
| `role_matches[].matches[].name` | string | no |  |
| `role_matches[].matches[].score` | integer | no | 0-100 similarity to the role description. |
