# 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: Scheduled Emails.

### Scheduled Emails

Superhuman-style scheduled candidate email sends. Draft, schedule, edit, cancel, or send-now before delivery.

- `POST /emails` - Send candidate email now
- `POST /emails/from-description` - Stage and stream a candidate email draft
- `GET /emails/from-description/{review_token}` - Get a staged email draft
- `PATCH /emails/from-description/{review_token}` - Patch fields on a staged email draft
- `POST /emails/from-description/{review_token}/revise` - Apply a free-text rewrite to a staged email draft
- `POST /emails/from-description/{review_token}/confirm` - Send or schedule a drafted email
- `GET /scheduled-emails` - List scheduled emails
- `POST /scheduled-emails` - Create scheduled email
- `GET /scheduled-emails/{id}` - Get scheduled email
- `PATCH /scheduled-emails/{id}` - Update scheduled email
- `DELETE /scheduled-emails/{id}` - Cancel scheduled email
- `POST /scheduled-emails/{id}/send-now` - Send scheduled email now

---

## Scheduled Emails

Superhuman-style scheduled candidate email sends. Draft, schedule, edit, cancel, or send-now before delivery.

### POST /emails

**Send candidate email now**

Send an email to a candidate immediately. Uses the company reply-to alias and threads with any existing conversation. Mirrors the send_email action. For the rich compose-draft-revise flow used by the dashboard and Slack, see POST /emails/from-description.

Operation ID: `sendCandidateEmail`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `candidate_id` | string | yes |  |
| `subject` | string | yes |  |
| `body` | string | yes |  |
| `to_address` | string | no | Override recipient (default uses candidate.email) |
| `job_id` | string | no |  |
| `application_id` | string | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Email accepted for immediate send | { success, data: object } |

### POST /emails/from-description

**Stage and stream a candidate email draft**

Takes the recruiter intent ("send Alice the next steps", "reject Ben warmly", or freeform instructions) and streams a subject + body via Server-Sent Events. The output is funnelled through the shared brand-voice + anti-AI-tics post-processor. The response Content-Type is text/event-stream; each event is `data: <json>\n\n` where <json> is one of `{type:"meta",review_token}`, `{type:"streaming",partial:{subject?,body?}}`, `{type:"complete",draft:{subject,body,schedule_at?,template_chip?}}`, `{type:"meta_end",email_draft_id}`, or `{type:"error",message}`. The stream terminates with `data: [DONE]\n\n`. This endpoint stages a draft only - call POST /emails/from-description/{review_token}/confirm to send it.

Operation ID: `composeEmailFromDescription`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `candidate_id` | string | yes | External candidate ID (cnd_ prefix) |
| `application_id` | string | no | Optional external application ID (app_ prefix) for role-aware tone |
| `intent` | string (`advance`, `reject`, `schedule`, `offer`, `freeform`) | no | What kind of email to write. Defaults to freeform. |
| `instructions` | string | no | Free-text hints about what the email should say |
| `schedule_at` | string | no | Optional ISO-8601 timestamp. If set, confirm schedules the send. |
| `source` | string (`slack`, `mcp`, `dashboard`, `api`) | no | Provenance tag stored on the draft row. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | SSE stream of draft events. Content-Type: text/event-stream. | string |
| `400` | candidate_id missing or invalid, schedule_at is not ISO-8601, or body was not JSON. |  |
| `404` | Candidate not found in this company. |  |

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

**Get a staged email draft**

Read current state of a staged email draft: subject, body, schedule, gaps, and the draft-generation task status. Consumers poll this until task_status === "completed" before confirming.

Operation ID: `getEmailDraft`

**Path parameters**

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

**Responses**

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

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

**Patch fields on a staged email draft**

Update one or more fields on an in-flight draft (subject, body, schedule_at, template_chip). Recomputes gaps.

Operation ID: `updateEmailDraft`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | string | no |  |
| `body` | string | no |  |
| `schedule_at` | string \| null | no | ISO-8601 timestamp or null to clear |
| `template_chip` | string \| null | no |  |

**Responses**

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

### POST /emails/from-description/{review_token}/revise

**Apply a free-text rewrite to a staged email draft**

Apply a natural-language instruction ("make it warmer", "cut to two paragraphs", "move the ask to the first line") to a staged email draft. Applies the company brand voice and anti-AI-tics guardrails.

Operation ID: `reviseEmailDraft`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `instruction` | string | yes | The change the user wants, in their own words. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Updated subject + body | { success, data: object } |
| `422` | Invalid instruction or draft in a non-revisable state. |  |

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

**Send or schedule a drafted email**

Promote a staged email draft by dispatching through the existing send_email / schedule_email action handlers. When schedule_at is set on the draft, the draft is scheduled instead of sent immediately. Refuses with 409 `{ error: { type: "draft_has_gaps", gaps: string[] } }` when subject or body is empty. There is no `force` bypass - partners who need to send a bare email use POST /emails directly.

Operation ID: `confirmEmailDraft`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Email sent or scheduled. Response body contains `email_message_id` + `email_thread_id` (immediate send) OR `scheduled_email_id` (scheduled send). | { success, data: object } |
| `409` | Draft has gaps - subject or body is empty. Fill them via PATCH /emails/from-description/{review_token} or revise, then retry. |  |
| `422` | Draft missing, expired, or already confirmed. |  |

### GET /scheduled-emails

**List scheduled emails**

List scheduled (and historical) email sends for the company. Scheduled emails sit in status='scheduled' until the cron fires them, then move to status='sent'. Requires emails:read scope.

Operation ID: `listScheduledEmails`

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string (`draft`, `scheduled`, `sending`, `sent`, `cancelled`, `failed`) | no |  |
| `candidate_id` | string | no |  |
| `scheduled_before` | string (date-time) | no |  |
| `scheduled_after` | string (date-time) | no |  |
| `limit` | integer, max 100, default 25 | no |  |
| `cursor` | string | no | scheduled_at cursor for forward pagination. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | List of scheduled emails | { success, data: array of ScheduledEmail, meta } |

### POST /scheduled-emails

**Create scheduled email**

Schedule an email to a candidate. Row sits in status='scheduled' until scheduled_at passes; the cron picks it up on the next minute tick and sends via the same path as immediate sends. Requires emails:write scope. Idempotent via Idempotency-Key header.

Operation ID: `createScheduledEmail`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `candidate_id` | string | yes | External candidate ID (cnd_). |
| `to_address` | string | no | Optional override for the destination email. Defaults to the candidate's primary email. |
| `cc_addresses` | array of string | no |  |
| `bcc_addresses` | array of string | no |  |
| `subject` | string | yes |  |
| `body_html` | string | no | Optional HTML body. Either body_html or body_text is required. |
| `body_text` | string | no | Optional plain-text body. Either body_html or body_text is required. |
| `scheduled_at` | string (date-time) | yes | When to fire. Must be in the future. |
| `application_id` | string | no |  |
| `job_id` | string | no |  |
| `created_by` | string | no | Optional external user ID (usr_) to credit. Defaults to the company's first admin. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Scheduled email created | { success, data: ScheduledEmail } |
| `422` | Validation error |  |

### GET /scheduled-emails/{id}

**Get scheduled email**

Operation ID: `getScheduledEmail`

**Path parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Scheduled email external ID (sem_ prefix). |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Scheduled email details | { success, data: ScheduledEmail } |
| `404` | Not found |  |

### PATCH /scheduled-emails/{id}

**Update scheduled email**

Edit a scheduled email before it fires. Only permitted when status='scheduled'. Once the cron has claimed the row the API returns 422.

Operation ID: `updateScheduledEmail`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `to_address` | string | no |  |
| `cc_addresses` | array of string | no |  |
| `bcc_addresses` | array of string | no |  |
| `subject` | string | no |  |
| `body_html` | string \| null | no |  |
| `body_text` | string \| null | no |  |
| `scheduled_at` | string (date-time) | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Updated | { success, data: ScheduledEmail } |
| `404` | Not found |  |
| `422` | No longer editable (already sent/cancelled/failed) or validation error |  |

### DELETE /scheduled-emails/{id}

**Cancel scheduled email**

Soft-cancel a scheduled email by setting status='cancelled'. Row is preserved for audit. Only permitted when status='scheduled'.

Operation ID: `cancelScheduledEmail`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Cancelled | { success, data: ScheduledEmail } |
| `422` | Not in a cancellable state |  |

### POST /scheduled-emails/{id}/send-now

**Send scheduled email now**

Fire a scheduled email immediately by setting scheduled_at to now(). The cron picks it up on the next tick (up to ~60s). Only permitted when status='scheduled'.

Operation ID: `sendScheduledEmailNow`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Scheduled for immediate send | { success, data: ScheduledEmail } |
| `422` | Not in a sendable state |  |

## 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 |  |
| `prev_cursor` | string \| null | no |  |

### ScheduledEmail

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `candidate_id` | string | no |  |
| `application_id` | string \| null | no |  |
| `job_id` | string \| null | no |  |
| `thread_id` | string \| null | no |  |
| `email_message_id` | string \| null | no |  |
| `created_by` | string \| null | no |  |
| `to_address` | string | no |  |
| `cc_addresses` | array of string | no |  |
| `bcc_addresses` | array of string | no |  |
| `subject` | string | no |  |
| `body_html` | string \| null | no |  |
| `body_text` | string \| null | no |  |
| `status` | string (`draft`, `scheduled`, `sending`, `sent`, `cancelled`, `failed`) | no |  |
| `scheduled_at` | string (date-time) | no |  |
| `sent_at` | string (date-time) \| null | no |  |
| `cancelled_at` | string (date-time) \| null | no |  |
| `last_error` | string \| null | no |  |
| `created_at` | string (date-time) | no |  |
| `updated_at` | string (date-time) | no |  |
