# 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 1.0.0. 161 paths, 239 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

Paginated list endpoints declare the parameters they support below. For cursor-based lists, read `meta.next_cursor` from a response and pass it back as `after`. Bounded catalogue and configuration lists return their full result and do not take a cursor.

| 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 |
| `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. |
| `Idempotency-Key` | header | string | Unique key for this mutation. Repeating the same request with the same key within 24 hours returns the saved response without running it again. |

## 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 - public API release history
- https://lovelio.ai/llms-full.txt - all of the above inlined in one fetch

## Endpoint index

Filtered to: Submissions.

### Submissions

Client-facing candidate submissions with a tokenised review link and a live feedback loop

- `GET /submissions` - List submissions
- `POST /submissions` - Create and send a submission
- `GET /submissions/{id}` - Get submission

---

## Submissions

Client-facing candidate submissions with a tokenised review link and a live feedback loop

### GET /submissions

**List submissions**

Operation ID: `listSubmissions`

Scopes: `submissions:read`

**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 |
| `job_id` | string | no | Filter to one job (job_ id). |
| `client_id` | string | no | Filter to one client (cli_ id). |
| `status` | string (`pending`, `viewed`, `partially_responded`, `completed`, `expired`, `revoked`) | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Paginated list of submissions, newest first | { success, data: array of Submission, meta } |

### POST /submissions

**Create and send a submission**

Creates a submission on a job and emails the client contact a tokenised review link. Items without a summary get an AI-drafted one. Candidates still in triage (funnel/maybe/yes/no) move to the submitted stage automatically; candidates already client-side keep their stage. The contact defaults to the job's contact, then the client's primary contact. Anyone already sent to this client for this job is refused with a 400 naming them - pass allow_resubmit: true to send anyway. identity_visibility controls how much of each candidate the client sees, one level for the whole send. Same write path as the in-product flow. Requires submissions:write.

Operation ID: `createSubmission`

Scopes: `submissions:write`

**Header parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | yes | Unique key for this mutation. Repeating the same request with the same key within 24 hours returns the saved response without running it again. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `job_id` | string | yes |  |
| `items` | array of object | yes |  |
| `items[].application_id` | string | yes |  |
| `items[].summary` | string | no | Client-facing pitch. Omit to have AI draft it. Plain text with light formatting: blank lines start a new paragraph, lines starting "- " become a bullet list, "1. " a numbered list, and **word** renders bold in the email and on the review page. |
| `client_contact_id` | string | no | Must belong to the job's client. Defaults to the job's contact, then the primary contact. |
| `title` | string | no |  |
| `greeting` | string | no | Opening line of the email, no trailing comma (e.g. "Hi Brett"). Defaults to "Hi {first name}". |
| `email_subject` | string | no | Subject line of the email. Defaults to "Candidates for the role of {job title}", singular for a submission of one. Stored on the submission, so a resend repeats it rather than generating a different line. |
| `headline` | string | no | The headline at the top of the client review page - the largest line the contact reads. Defaults to "Hi {first name}, {n} candidates for your review.". Stored on the submission, so a resend shows the page the client first read. |
| `intro_body` | string | no | Everything said above the candidates, in the consultant's own words, shown in the email and on the review page. Defaults to "I am delighted to submit {first names} for your role of {job title}. Please see specific details below. I look forward to your feedback when you have time to review." signed off by the consultant. Stored on the submission, so a resend repeats what the client first read. Plain text with light formatting: blank lines start a new paragraph, lines starting "- " become a bullet list, "1. " a numbered list, and **word** renders bold. |
| `message` | string | no | Legacy covering note. It renders under the DEFAULT intro and is ignored when intro_body is set. Use intro_body instead. |
| `allow_resubmit` | boolean, default false | no | Send even when a candidate on the list has already gone to this client for this job. Off by default so the same CV is never sent twice by accident. |
| `identity_visibility` | string (`full`, `first_name`, `anonymous`), default "full" | no | How much of each candidate the client sees. full: the whole profile. first_name: first name only, surname and LinkedIn removed. anonymous: no name, no LinkedIn, and employers and schools withheld. One level for the whole send. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Created submission. email_sent false means the link works but the email failed - resend from the product. | { success, data: Submission } |

### GET /submissions/{id}

**Get submission**

Operation ID: `getSubmission`

Scopes: `submissions:read`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Submission with per-candidate response state | { success, data: Submission } |
| `404` | Submission not found |  |

## Schemas

Objects referenced by the operations above.

### Meta

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

### Submission

A submission of candidates on a job, sent to the job's client contact as a tokenised no-login link. The contact requests interviews, passes with a reason, or asks questions; every response updates the pipeline and notifies the consultant.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `job_id` | string | no |  |
| `job_title` | string \| null | no | The job's title, embedded so list consumers never need a lookup per row. |
| `client_id` | string | no |  |
| `client_name` | string \| null | no | The client's name, embedded so list consumers never need a lookup per row. |
| `client_contact_id` | string | no |  |
| `status` | string (`pending`, `viewed`, `partially_responded`, `completed`, `expired`, `revoked`) | no |  |
| `title` | string \| null | no |  |
| `email_subject` | string \| null | no | The subject line the client contact received. Null on submissions sent before subjects were editable (2026-08-16); those fall back to the generated default when resent. |
| `greeting` | string \| null | no | The opening line the client contact received, no trailing comma (e.g. "Hi Brett"). Null on submissions sent before the greeting was stored (2026-08-16); those fall back to "Hi {first name}" when resent. |
| `headline` | string \| null | no | The headline at the top of the client review page, in the consultant's own words. Null means the generated default ("Hi {first name}, {n} candidates for your review."). |
| `intro_body` | string \| null | no | What the consultant wrote above the candidates, in their own words, shown in the email and on the review page. Null means the generated default ("I am delighted to submit {first names} for your role of {job title}. Please see specific details below..." signed off by the consultant). |
| `message` | string \| null | no | Legacy covering note, kept on submissions sent before intro_body existed (2026-08-16). It renders under the default intro. Write intro_body instead. |
| `identity_visibility` | string (`full`, `first_name`, `anonymous`) | no | How much of each candidate the client sees on the review link. full: name, LinkedIn, employers and schools. first_name: first name only, no surname and no LinkedIn. anonymous: no name (each candidate is "Candidate A", "Candidate B"), no LinkedIn, and employers and schools withheld. Redaction is applied server-side before the payload leaves, and to the email as well as the page. |
| `url` | string | no | The client-facing review link. No login - anyone with the link can respond. Expires with the submission. |
| `expires_at` | string (date-time) | no | 7 days from send; resending extends it. |
| `first_viewed_at` | string (date-time) \| null | no |  |
| `completed_at` | string (date-time) \| null | no |  |
| `created_at` | string (date-time) | no |  |
| `items` | array of SubmissionItem | no |  |
