# 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: Clients.

### Clients

- `GET /clients` - List clients
- `POST /clients` - Create client
- `GET /clients/{id}` - Get client
- `PATCH /clients/{id}` - Update client
- `DELETE /clients/{id}` - Delete client
- `GET /clients/{id}/contacts` - List client contacts
- `POST /clients/{id}/contacts` - Add client contact
- `PATCH /clients/{id}/contacts/{contactId}` - Update client contact
- `DELETE /clients/{id}/contacts/{contactId}` - Delete client contact

---

## Clients

### GET /clients

**List clients**

Operation ID: `listClients`

**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 |
| `search` | string | no | Case-insensitive substring match on the client name. |
| `domain` | string | no | Exact match on the normalised bare domain. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Paginated list of clients | { success, data: array of Client, meta } |

### POST /clients

**Create client**

Creates a client. Provide a website and Lovelio fills in the rest asynchronously: logo, industry, locations, and a first-pass Client DNA built from the client's public footprint. Same write path as the in-product flow. Requires clients:write.

Operation ID: `createClient`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | Required if website is omitted. |
| `website` | string | no | Providing this kicks off enrichment + the Client DNA first pass. |
| `industry` | string | no |  |
| `notes` | string | no |  |
| `allow_duplicate` | boolean | no | By default a create matching an existing client by name or domain is refused with a message naming the existing record's owner. Set true to create a second record deliberately. |
| `terms` | object | no | Initial agreed terms (importing your book) - becomes the client's fee schedule v1. Later changes go through the set_client_fee_terms action. |
| `terms.standard_fee_percent` | number | no | A flat percent - becomes a single fee band. |
| `terms.payment_terms_days` | integer | no |  |
| `terms.guarantee_days` | integer | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Created client (enrichment_status "pending" when a website was given) | { success, data: Client } |

### GET /clients/{id}

**Get client**

Operation ID: `getClient`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Client details | { success, data: Client } |
| `404` | Client not found |  |

### PATCH /clients/{id}

**Update client**

The agency self-client (your own agency's record, used for internal hires) is readable but not editable over the API - PATCH returns 409, like DELETE.

Operation ID: `updateClient`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `website` | string | no | Also refreshes the normalised domain. Adding a website to a client that was never enriched kicks off enrichment + the Client DNA first pass, same as providing it at create time (enrichment_status returns "pending"). |
| `industry` | string \| null | no |  |
| `description` | string \| null | no |  |
| `locations` | array of string | no |  |
| `address` | string \| null | no |  |
| `notes` | string \| null | no |  |
| `logo_url` | string \| null | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Updated client | { success, data: Client } |
| `409` | The agency self-client cannot be edited through the API |  |

### DELETE /clients/{id}

**Delete client**

Soft deletes a client. Jobs keep running; their client_id clears only if the row is hard-deleted (never over this API).

Operation ID: `deleteClient`

**Path parameters**

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

**Responses**

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

### GET /clients/{id}/contacts

**List client contacts**

Operation ID: `listClientContacts`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Contacts at this client, primary first | { success, data: array of ClientContact, meta } |

### POST /clients/{id}/contacts

**Add client contact**

Lovelio resolves the person behind the contact: a matching email, LinkedIn URL or phone links the new contact to an existing person record. A person already on this client's contact list is returned as-is rather than duplicated (still 201).

Operation ID: `addClientContact`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `email` | string | no |  |
| `phone` | string | no | Digits with an optional leading + country code; spaces, hyphens, dots and parentheses allowed. Rejected with a validation error otherwise. |
| `title` | string | no |  |
| `reports_to` | string | no | Another contact (cct_ id) on this client this person reports to. Must belong to the same client. |
| `is_primary` | boolean | no |  |
| `notes` | string | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Created contact | { success, data: ClientContact } |
| `422` | Validation error - e.g. phone is not a valid phone number |  |

### PATCH /clients/{id}/contacts/{contactId}

**Update client contact**

Patching name renames the person, not just this contact - the new name shows everywhere that person appears.

Operation ID: `updateClientContact`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no |  |
| `email` | string \| null | no |  |
| `phone` | string \| null | no | Digits with an optional leading + country code; spaces, hyphens, dots and parentheses allowed. Rejected with a validation error otherwise. |
| `title` | string \| null | no |  |
| `reports_to` | string \| null | no | Another contact (cct_ id) on the same client this person reports to. Send null to clear. Cannot be the contact itself. |
| `is_primary` | boolean | no |  |
| `notes` | string \| null | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Updated contact | { success, data: ClientContact } |
| `422` | Validation error - e.g. phone is not a valid phone number |  |

### DELETE /clients/{id}/contacts/{contactId}

**Delete client contact**

Soft deletes a contact. Any job pointing at it keeps the reference cleared by the composite FK rules on the job side.

Operation ID: `deleteClientContact`

**Path parameters**

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

**Responses**

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

## Schemas

Objects referenced by the operations above.

### Client

A company the agency recruits for - a record inside the account, never a tenant. Creating one with a website kicks off AI enrichment (logo, industry, locations) and an automatic first-pass Client DNA build from the client's public footprint.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `account_id` | string | no |  |
| `name` | string | no |  |
| `website` | string \| null | no |  |
| `domain` | string \| null | no | Normalised bare domain, e.g. "acme.com". Derived from website. |
| `logo_url` | string \| null | no |  |
| `industry` | string \| null | no |  |
| `description` | string \| null | no |  |
| `locations` | array of string | no |  |
| `address` | string \| null | no | One-line postal address, shown on the client's terms document. |
| `fee_schedule` | object | no | The fee terms in force: the client's own terms, or the agency standard (is_standard: true). Bands are slabs - the whole salary is charged at the rate of the band it lands in. Changing a client's terms goes through the set_client_fee_terms action (admin-only), never a PATCH. This is the agency's price list: the whole object is null without the clients:financials:read scope. |
| `fee_schedule.currency` | string | no |  |
| `fee_schedule.fee_basis` | string (`package`, `base`) | no | What the percent applies to: total salary package or base salary. |
| `fee_schedule.tiers` | array of object | no | Slab bands, ascending. Salaries from `from` upward (until the next band) pay `percent` on the whole salary. |
| `fee_schedule.tiers[].from` | number | no |  |
| `fee_schedule.tiers[].percent` | number | no |  |
| `fee_schedule.minimum_fee` | number \| null | no |  |
| `fee_schedule.guarantee_days` | integer \| null | no |  |
| `fee_schedule.guarantee_remedy` | string (`replacement`, `refund_full`, `refund_pro_rata`, `refund_half`, `replacement_or_refund_half`, `none`) | no |  |
| `fee_schedule.guarantee_notes` | string \| null | no |  |
| `fee_schedule.payment_terms_days` | integer \| null | no |  |
| `fee_schedule.is_standard` | boolean | no | True when the client is on the agency standard (no approved variation). |
| `fee_schedule.version` | integer | no | Schedule version - increments on every approved change. |
| `notes` | string \| null | no |  |
| `enrichment_status` | string (`none`, `pending`, `complete`, `failed`) | no | Website enrichment progress. "none" for clients created without a website. |
| `dna_status` | string (`none`, `building`, `complete`, `failed`) | no | Client DNA build progress. Assessments for this client's jobs use the DNA once complete. |
| `created_at` | string (date-time) | no |  |
| `updated_at` | string (date-time) | no |  |

### ClientContact

A person at the client: hiring manager, HR, finance. Contacts receive submissions and sit on jobs, always scoped to their client.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `client_id` | string | no |  |
| `person_id` | string \| null | no | The person behind this contact - a candidate id. One human can hold both a candidate record and contact roles; this is the same id GET /candidates serves them under. |
| `name` | string | no | The person's name. It lives on the person record and is shared across every role they hold, so renaming it here renames them everywhere. |
| `email` | string \| null | no |  |
| `phone` | string \| null | no |  |
| `title` | string \| null | no |  |
| `reports_to` | string \| null | no | Another contact (cct_ id) on the same client this person reports to. Builds the client org chart. |
| `is_primary` | boolean | no | One primary per client; setting a new primary demotes the old one. |
| `origin` | string (`relationship`, `works_here`) | no | "relationship" - somebody put this person on the client, and they are who the agency deals with. "works_here" - derived automatically because the person's own employment record says they work there; these are listed separately in the app, carry no work email, and are never the recipient of a submission or an interview invitation. A consultant promotes one to "relationship" by hand. |
| `notes` | string \| null | no |  |
| `created_at` | string (date-time) | no |  |
| `updated_at` | string (date-time) | 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 |  |
