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

### Interviews

Interview scheduling and feedback

- `GET /interviews` - List interviews
- `POST /interviews` - Schedule interview
- `GET /interviews/{id}` - Get interview
- `PATCH /interviews/{id}` - Update interview
- `DELETE /interviews/{id}` - Delete interview
- `GET /interviews/{id}/context` - Get interview preparation and debrief
- `POST /interviews/{id}/cancel` - Cancel interview
- `POST /interviews/{id}/scorecard` - Submit interview scorecard
- `POST /interviews/{id}/schedule-link` - Generate candidate self-schedule link
- `POST /interviews/from-description` - Stage an interview draft for an application
- `GET /interviews/from-description/{review_token}` - Get a staged interview draft
- `PATCH /interviews/from-description/{review_token}` - Patch fields on a staged interview draft
- `POST /interviews/from-description/{review_token}/confirm` - Book the drafted interview
- `POST /interviews/{id}/action` - Stage a reschedule or cancel draft for an existing interview
- `GET /interviews/action-drafts/{review_token}` - Get a staged reschedule / cancel draft
- `PATCH /interviews/action-drafts/{review_token}` - Patch fields on a staged reschedule / cancel draft
- `POST /interviews/action-drafts/{review_token}/confirm` - Confirm a reschedule or cancel draft
- `POST /scorecards/from-description` - Stage a scorecard draft for an interview
- `GET /scorecards/from-description/{review_token}` - Get a staged scorecard draft
- `PATCH /scorecards/from-description/{review_token}` - Patch fields on a staged scorecard draft
- `POST /scorecards/from-description/{review_token}/confirm` - Submit a staged scorecard draft

---

## Interviews

Interview scheduling and feedback

### GET /interviews

**List interviews**

Operation ID: `listInterviews`

Scopes: `interviews: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 |
| `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 interviews | { success, data: array of Interview, meta } |

### POST /interviews

**Schedule interview**

Schedules an interview. A client interview (flavour: client) with scheduled_at and client_contact_ids books an exact time with the invite fan-out: the candidate and the client contact(s) get the invitation and .ics at booking, and a submitted application moves to client_interview. An internal interview (the default flavour) moves a yes application to screen. A client interview is the client meeting the candidate, so three things are refused on it: format video (and video_link - the agency does not run the call), interviewer_ids or hiring_manager_id (nobody from the agency attends), and offering the candidate a set of times to pick from (those times come from the agency's calendars).

Operation ID: `createInterview`

Scopes: `interviews: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`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `application_id` | string | yes |  |
| `scheduled_at` | string (date-time) | no |  |
| `format` | string (`phone`, `video`, `in_person`, `technical`) | no |  |
| `flavour` | string (`internal`, `client`), default "internal" | no | client books the candidate with the client and moves a submitted application to client_interview. internal (the default) is the agency's own screen and moves a yes application to screen. |
| `submission_id` | string | no | Link a client interview to the submission it came from (sub_ id). |
| `client_contact_ids` | array of string | no | Client-side attendees (cct_ ids), client interviews only. Up to 3. Each must have an email on file. They receive the calendar invite and .ics at booking; they never constrain the offered times. |
| `round` | integer | no |  |
| `duration_minutes` | integer | no |  |
| `video_link` | string | no | A pasted external meeting link (Teams/Zoom/Meet). When absent, a video interview gets a Lovelio call room. Internal interviews only - refused on a client interview, as is format video. |
| `location` | string | no | Where an in-person interview happens. Rides the calendar invite's LOCATION line and the invitation emails. |
| `scheduling_mode` | string (`propose`, `automated`, `self_schedule`) | no |  |
| `interviewer_ids` | array of string | no | Agency-side attendees (usr_ ids). Internal interviews only - a client interview is the client meeting the candidate and carries none. |
| `notes_for_candidate` | string | no |  |
| `notes_for_interviewer` | string | no |  |
| `ignore_conflicts` | boolean, default false | no | Booking an exact time checks every attendee's free/busy (Lovelio calendar, plus Google where connected) and a clash refuses with 409 SCHEDULING_CONFLICT. Pass true to book through a known clash - an explicit override, never the default, so a plain call can never silently double-book. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Interview scheduled. meta.warnings lists attendees whose connected Google Calendar could not be read - their Lovelio events were still checked - plus a quiet note for any interviewer whose preferred interview times the booked slot falls outside (preferences never block a booking). | { success, data: Interview, meta } |
| `409` | An attendee is busy at the requested time (SCHEDULING_CONFLICT). data carries conflicts (who is busy and when), suggestions (up to 3 conflict-free alternative windows, which also respect the interviewers' preferred interview times - a window somebody excluded is never suggested) and warnings (Google calendars that could not be read). Retry with ignore_conflicts: true to book it anyway. | { success, data: object, error } |

### GET /interviews/{id}

**Get interview**

Operation ID: `getInterview`

Scopes: `interviews:read`

**Path parameters**

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

**Responses**

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

### PATCH /interviews/{id}

**Update interview**

Operation ID: `updateInterview`

Scopes: `interviews:write`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `outcome` | string (`pending`, `passed`, `failed`, `no_show`, `cancelled`) | no | Record the interview result. Preferred over status - it distinguishes a pass from a fail. Wins when both are sent. Recording an outcome NEVER moves the application: how the interview went and what happens next are separate decisions, and the second one is a person's. Move the candidate with POST /applications/{id}/stage once that decision is made. |
| `status` | string (`scheduled`, `completed`, `cancelled`, `no_show`) | no | Legacy coarse status; completed records a pass. Use outcome to record a fail. |
| `format` | string (`phone`, `video`, `in_person`, `technical`) | no |  |
| `scheduled_at` | string (date-time) | no | Moving the time re-checks every attendee's free/busy (Lovelio calendar, plus Google where connected) - a clash refuses with 409 SCHEDULING_CONFLICT. The interview's own calendar event never counts against its new time, so a small nudge is always clean. Moving the time also voids the candidate's confirmation and moves the linked calendar event (and the organiser's Google event) to the new slot. |
| `ignore_conflicts` | boolean, default false | no | Pass true to move the time through a known clash - an explicit override, never the default, so a plain call can never silently double-book. |
| `video_link` | string | no |  |
| `duration_minutes` | integer | no |  |
| `round` | integer | no |  |
| `notes_for_candidate` | string | no |  |
| `notes_for_interviewer` | string | no |  |
| `feedback` | string | no |  |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Updated | { success, data: Interview } |
| `409` | An attendee is busy at the new scheduled_at (SCHEDULING_CONFLICT). data carries conflicts (who is busy and when), suggestions (up to 3 conflict-free alternative windows) and warnings (Google calendars that could not be read). Retry with ignore_conflicts: true to move it anyway. | { success, data: object, error } |

### DELETE /interviews/{id}

**Delete interview**

Soft deletes an interview. The record is hidden from all queries but not permanently removed. Requires interviews:write scope.

Operation ID: `deleteInterview`

Scopes: `interviews:write`

**Path parameters**

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

**Responses**

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

### GET /interviews/{id}/context

**Get interview preparation and debrief**

The complete interview read: attendees and confirmation, the 30-second preparation digest, one scorecard per interviewer, the deterministic consolidated verdict, and the legal next actions. Recording an outcome never moves the application stage.

Operation ID: `getInterviewContext`

Scopes: `interviews:read`

**Path parameters**

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

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Interview context. | { success, data: InterviewContext } |

### POST /interviews/{id}/cancel

**Cancel interview**

Operation ID: `cancelInterview`

Scopes: `interviews:write`

**Path parameters**

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

**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. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Cancelled | { success, data: Interview } |

### POST /interviews/{id}/scorecard

**Submit interview scorecard**

Compatibility door for submitting the authenticated interviewer's scorecard. Scores map section names or section ids to a 1-5 score. Lovelio writes through the same per-interviewer form used by the product, consolidates all interviewer cards, records the interview outcome, and never moves the application stage. Requires interviews:write.

Operation ID: `submitScorecard`

Scopes: `interviews:write`

**Path parameters**

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

**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 |
| --- | --- | --- | --- |
| `scores` | object | no | Map of criterion name to numeric score (e.g. { "Technical": 4 }). |
| `recommendation` | string (`strong_hire`, `hire`, `no_hire`, `strong_no_hire`) | yes |  |
| `notes` | string | no | Free-text scorecard notes. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | Scorecard stored | { success, data: object } |
| `422` | Missing or invalid recommendation. |  |

### POST /interviews/{id}/schedule-link

**Generate candidate self-schedule link**

Creates a one-time, expiring link the candidate uses to pick an interview time from the interviewer's live Google Calendar availability. Interview must be unscheduled. Optionally emails the link to the candidate. Requires interviews:write.

Operation ID: `generateInterviewScheduleLink`

Scopes: `interviews:write`

**Path parameters**

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

**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`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `send_email` | boolean, default true | no | Email the candidate the link automatically. |
| `ttl_days` | integer, 1-30, default 7 | no | Link validity window in days. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Schedule link created | { success, data: object } |
| `409` | Interview is already scheduled or cancelled. |  |

### POST /interviews/from-description

**Stage an interview draft for an application**

Stage a structured interview draft (datetime, format, interviewer, duration, location, notes) and get back the predicted defaults + remaining gaps. Unlike /emails/from-description there is no streaming prose - interview fields are discrete selects, so this returns a JSON object synchronously. Returns { review_token, draft, gaps, predicted }. Consumers fill gaps via PATCH /interviews/from-description/{review_token} and book via POST /interviews/from-description/{review_token}/confirm.

Operation ID: `prepareInterviewFromDescription`

Scopes: `interviews: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 |
| --- | --- | --- | --- |
| `application_id` | string | yes | External application ID (app_ prefix) |
| `scheduled_at` | string | no | Optional ISO-8601 timestamp to pre-fill the datetime |
| `format` | string (`phone`, `video`, `in_person`, `technical`, `panel`) | no | Optional pre-selected format. Defaults to the job's default_interview_format. |
| `interviewer_id` | string | no | Optional hiring_manager UUID. Defaults to the job's hiring_manager_id. |
| `duration_min` | number | no | Optional duration in minutes. Defaults to 30. |
| `location` | string | no | Optional location (required at confirm time when format=in_person). |
| `notes` | string | no | Optional interviewer notes / prep links. |
| `source` | string (`slack`, `mcp`, `dashboard`, `api`) | no | Provenance tag stored on the draft row. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Draft staged. Body contains review_token, draft, gaps, predicted. | { success, data: object } |
| `404` | Application not found in this company. |  |
| `422` | application_id missing or invalid. |  |

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

**Get a staged interview draft**

Read current state of a staged interview draft: scheduled_at, duration_min, format, interviewer_id, location, notes + gaps.

Operation ID: `getInterviewDraft`

Scopes: `interviews:read`

**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 /interviews/from-description/{review_token}

**Patch fields on a staged interview draft**

Update one or more fields on an in-flight interview draft. Recomputes gaps. Fields: scheduled_at, duration_min, format, interviewer_id, location, notes. There is no revise endpoint - interview fields are structured; use PATCH to edit.

Operation ID: `updateInterviewDraft`

Scopes: `interviews:write`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scheduled_at` | string \| null | no | ISO-8601 timestamp or null to clear |
| `duration_min` | number | no |  |
| `format` | string (`phone`, `video`, `in_person`, `technical`, `panel`) \| null | no |  |
| `interviewer_id` | string \| null | no |  |
| `location` | string \| null | no |  |
| `notes` | string \| null | no |  |

**Responses**

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

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

**Book the drafted interview**

Promote a staged interview draft by dispatching through the existing schedule_interview action handler. Refuses with 409 `{ error: { type: "draft_has_gaps", gaps: string[] } }` when scheduled_at, format, or interviewer_id is empty (or when format=in_person without a location). There is no `force` bypass - partners who need to create a bare interview use POST /interviews directly. Booking also checks every attendee's free/busy: a clash refuses with 409 SCHEDULING_CONFLICT naming who is busy, with up to 3 conflict-free suggestions. Re-confirm with `scheduled_at` (patches the draft to a suggested time) or `ignore_conflicts: true` to book through the clash.

Operation ID: `confirmInterviewDraft`

Scopes: `interviews:write`

**Path parameters**

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

**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`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scheduled_at` | string (date-time) | no | Re-confirm at this time (after a SCHEDULING_CONFLICT refusal offered suggestions). Patches the draft through the one update path before booking. |
| `ignore_conflicts` | boolean, default false | no | Book through a known free/busy clash - an explicit override, never the default. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Interview booked. Response body contains `interview_id`. | { success, data: object } |
| `409` | Draft has gaps (`{ error: { type: "draft_has_gaps", gaps } }` - fill via PATCH /interviews/from-description/{review_token} and retry), or an attendee is busy at the requested time (SCHEDULING_CONFLICT - data carries conflicts, suggestions and warnings; re-confirm with scheduled_at or ignore_conflicts: true). |  |
| `422` | Draft missing, expired, or already confirmed. |  |

### POST /interviews/{id}/action

**Stage a reschedule or cancel draft for an existing interview**

Stage a reschedule or cancel action against an existing interview. Returns { review_token, draft, gaps } that partners feed into /interviews/action-drafts/{review_token} to fill gaps and confirm. Reschedule requires new_scheduled_at at confirm time; cancel requires reason. Dispatches through the existing reschedule_interview / cancel_interview action handlers on confirm so every side-effect (activity log, webhook, calendar sync) stays in one place.

Operation ID: `prepareInterviewAction`

Scopes: `interviews:write`

**Path parameters**

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

**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 |
| --- | --- | --- | --- |
| `action` | string (`reschedule`, `cancel`) | yes |  |
| `new_scheduled_at` | string | no | ISO-8601 timestamp. Required at confirm time when action=reschedule. |
| `reason` | string | no | Free-text reason. Required at confirm time when action=cancel. |
| `notify_candidate` | boolean | no | Whether the candidate is emailed about the change. Defaults true. |
| `source` | string (`slack`, `mcp`, `dashboard`, `api`) | no | Provenance tag stored on the draft row. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Draft staged. Body contains review_token, draft, gaps, action. | { success, data: object } |
| `404` | Interview not found in this company. |  |
| `422` | Missing / invalid action, or interview already cancelled. |  |

### GET /interviews/action-drafts/{review_token}

**Get a staged reschedule / cancel draft**

Read current state of a staged reschedule / cancel draft: action, new_scheduled_at, reason, notify_candidate + gaps.

Operation ID: `getInterviewActionDraft`

Scopes: `interviews:read`

**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 /interviews/action-drafts/{review_token}

**Patch fields on a staged reschedule / cancel draft**

Update one or more fields on an in-flight reschedule / cancel draft. Recomputes gaps. Fields: new_scheduled_at, reason, notify_candidate.

Operation ID: `updateInterviewActionDraft`

Scopes: `interviews:write`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `new_scheduled_at` | string \| null | no | ISO-8601 timestamp or null to clear |
| `reason` | string \| null | no |  |
| `notify_candidate` | boolean | no |  |

**Responses**

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

### POST /interviews/action-drafts/{review_token}/confirm

**Confirm a reschedule or cancel draft**

Promote a staged reschedule / cancel draft by dispatching through the existing reschedule_interview / cancel_interview action handler. Refuses with 409 `{ error: { type: "draft_has_gaps", gaps: string[] } }` when a mandatory field is empty (reschedule → new_scheduled_at, cancel → reason). No force bypass. A reschedule also re-checks every attendee's free/busy (the interview's own calendar event never counts against its new time): a clash refuses with 409 SCHEDULING_CONFLICT naming who is busy, with up to 3 conflict-free suggestions. Re-confirm with `new_scheduled_at` (patches the draft to a suggested time) or `ignore_conflicts: true` to move through the clash.

Operation ID: `confirmInterviewActionDraft`

Scopes: `interviews:write`

**Path parameters**

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

**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`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `new_scheduled_at` | string (date-time) | no | Re-confirm a reschedule at this time (after a SCHEDULING_CONFLICT refusal offered suggestions). Patches the draft through the one update path before dispatching. |
| `ignore_conflicts` | boolean, default false | no | Move through a known free/busy clash - an explicit override, never the default. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Confirmed. Response body contains `interview_id`, `action`, `notify_candidate`. | { success, data: object } |
| `409` | Draft has gaps (`{ error: { type: "draft_has_gaps", gaps } }` - fill via PATCH /interviews/action-drafts/{review_token} and retry), or an attendee is busy at the new time (SCHEDULING_CONFLICT - data carries conflicts, suggestions and warnings; re-confirm with new_scheduled_at or ignore_conflicts: true). |  |
| `422` | Draft missing, expired, or already confirmed. |  |

### POST /scorecards/from-description

**Stage a scorecard draft for an interview**

Stage a scorecard draft for an interview. Lovelio looks up the job's scorecard criteria and pre-populates one score entry per criterion (score = null). Returns a `scd_` review token that every follow-up endpoint under /scorecards/from-description/{review_token} consumes. The multi-step flow mirrors /jobs/from-description and /emails/from-description. When the caller already has a full scorecard payload and does not need the draft-review flow, POST /interviews/{id}/scorecard remains the one-shot RPC.

Operation ID: `stageScorecardDraft`

Scopes: `interviews:write`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `interview_id` | string | yes | External interview id (ivw_...). |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Draft staged | { success, data: object } |
| `400` | Invalid or missing interview_id. |  |

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

**Get a staged scorecard draft**

Read the current state of a staged scorecard draft plus remaining mandatory and warn-level gaps.

Operation ID: `getScorecardDraft`

Scopes: `interviews:read`

**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 /scorecards/from-description/{review_token}

**Patch fields on a staged scorecard draft**

Update a staged scorecard draft. Accepts either a full scores-array replacement (scores: [...]) or a single-entry patch ({ criterion_id, score?, note? }). Also accepts overall_recommendation and feedback. Recomputes gaps.

Operation ID: `updateScorecardDraft`

Scopes: `interviews:write`

**Path parameters**

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scores` | array of object | no | Full replacement of the per-criterion scores array. |
| `scores[].criterion_id` | string | no |  |
| `scores[].criterion_name` | string | no |  |
| `scores[].category` | string | no |  |
| `scores[].scoring_type` | string | no |  |
| `scores[].score` | number \| null | no |  |
| `scores[].note` | string | no |  |
| `criterion_id` | string | no | Single-entry patch: patch this criterion with score + note. |
| `score` | number \| null | no |  |
| `note` | string | no |  |
| `overall_recommendation` | string (`strong_hire`, `hire`, `no_hire`, `strong_no_hire`) \| null | no |  |
| `feedback` | string \| null | no |  |

**Responses**

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

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

**Submit a staged scorecard draft**

Promote a staged scorecard draft by dispatching through the existing submit_scorecard + update_interview_outcome action handlers. Refuses with 409 `{ error: { type: "draft_has_gaps", gaps: { mandatory, warn } } }` when overall_recommendation is missing or every score is null. There is no `force` bypass - partners who need to submit a bare scorecard use POST /interviews/{id}/scorecard directly. The recommendation maps deterministically to interview outcome (strong_hire/hire -> Passed, no_hire/strong_no_hire -> Failed).

Operation ID: `confirmScorecardDraft`

Scopes: `interviews:write`

**Path parameters**

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

**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. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `201` | Scorecard submitted and interview outcome updated. | { success, data: object } |
| `400` | Draft missing, expired, or already confirmed. |  |
| `409` | Draft has gaps - overall_recommendation is missing or every score is null. Fill them via PATCH /scorecards/from-description/{review_token} then retry. |  |
| `422` | Draft missing, expired, or already confirmed. |  |

## Schemas

Objects referenced by the operations above.

### Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `success` | boolean | no |  |
| `data` | null | no |  |
| `meta` | Meta | no |  |
| `error` | object | no |  |
| `error.code` | string | no |  |
| `error.message` | string | no |  |
| `error.field` | string | no |  |
| `error.docs` | string (uri) | no |  |

### Interview

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no |  |
| `application_id` | string \| null | no |  |
| `job` | object \| null | no |  |
| `job.id` | string | no |  |
| `job.title` | string \| null | no |  |
| `candidate` | object \| null | no |  |
| `candidate.id` | string | no |  |
| `candidate.name` | string | no |  |
| `candidate.email` | string \| null | no |  |
| `round` | integer | no | Interview round number (1-based). |
| `format` | string (`phone`, `video`, `in_person`, `technical`) | no | Interview format. |
| `flavour` | string (`internal`, `client`) | no | internal = the agency's own screen (default); booking one moves a yes application to screen. client = the candidate meets the client; booking one moves a submitted application to client_interview. |
| `submission_id` | string \| null | no | The submission this client interview came from, when booked off client feedback (sub_ id). |
| `status` | string (`scheduled`, `completed`, `no_show`, `cancelled`) | no | Current interview status. Passed and failed interviews both read completed - see outcome for the actual result. |
| `outcome` | string (`pending`, `passed`, `failed`, `no_show`, `cancelled`) | no | The interview result. Unlike status, this distinguishes a pass from a fail. |
| `scheduled_at` | string (date-time) \| null | no |  |
| `candidate_confirmed_at` | string (date-time) \| null | no | Set when the candidate confirms attendance (e.g. replying YES to the WhatsApp confirmation). Null = not confirmed yet. Read-only. |
| `client_confirmed_at` | string (date-time) \| null | no | Set when a client contact confirms attendance from their own confirmation link. Independent of candidate_confirmed_at - either can be set without the other. Null = they have not told us. Read-only. |
| `duration_minutes` | integer \| null | no |  |
| `video_link` | string \| null | no |  |
| `location` | string \| null | no | Where an in-person interview happens. Rides the calendar invite's LOCATION line and the invitation emails. |
| `interviewers` | array of object | no |  |
| `interviewers[].id` | string | no |  |
| `interviewers[].name` | string | no |  |
| `interviewers[].email` | string \| null | no |  |
| `client_contacts` | array of object | no | Client-side attendees on a client interview (cct_ ids). They receive the calendar invite and .ics at booking; they never constrain the offered times. |
| `client_contacts[].id` | string \| null | no |  |
| `client_contacts[].name` | string \| null | no |  |
| `client_contacts[].email` | string | no |  |
| `notes_for_candidate` | string \| null | no |  |
| `notes_for_interviewer` | string \| null | no |  |
| `feedback` | object \| null | no | Structured interview feedback (JSONB). Populated by the unified interview form on submit: { recommendation, feedback, scores, decision_criteria, section_breakdown }. |
| `reminder_sent_at` | string (date-time) \| null | no | Set by the interview-reminder cron when the 24h reminder emails are sent. |
| `feedback_requested_at` | string (date-time) \| null | no | First time the hiring manager was asked for feedback after the interview ended. |
| `feedback_chase_count` | integer | no | Number of feedback requests that have been sent. Caps at 3, after which the item is enqueued as an hm_no_response review. |
| `google_event_id` | string \| null | no | Google Calendar event id when the interviewer has a connected calendar. Changes made in Google Calendar flow back to this row. |
| `calendar_provider` | string (`google`, null) \| null | no | Which calendar provider is authoritative for this interview. |
| `created_at` | string (date-time) | no |  |

### InterviewContext

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | ivw_ id. |
| `scheduledAt` | string (date-time) \| null | no |  |
| `whenLabel` | string | no |  |
| `durationMinutes` | integer \| null | no |  |
| `type` | string \| null | no |  |
| `round` | integer | no |  |
| `flavour` | string (`internal`, `client`) | no |  |
| `outcome` | string | no |  |
| `location` | string \| null | no |  |
| `candidate` | object | yes |  |
| `candidate.id` | string \| null | no |  |
| `candidate.name` | string | no |  |
| `candidate.photoUrl` | string \| null | no |  |
| `candidate.email` | string \| null | no |  |
| `job` | object | yes |  |
| `job.id` | string \| null | no |  |
| `job.title` | string \| null | no |  |
| `clientName` | string \| null | no |  |
| `applicationId` | string \| null | no |  |
| `applicationStage` | string \| null | no |  |
| `refereeMinRequired` | integer | no | How many references this application asked the candidate for. 2 when the application never set one. |
| `clientContactNames` | array of string | no |  |
| `clientContact` | object \| null | no | The first client contact on the invite with a record behind them - the person the emailed feedback form goes to. Null on internal interviews. |
| `clientContact.id` | string | no | cct_ id. |
| `clientContact.name` | string \| null | no |  |
| `prepUrl` | string \| null | no | The client interviewer feedback link. Legacy name - it carries no prep. |
| `scorecards` | array of object | yes |  |
| `scorecards[].instanceId` | string \| null | no |  |
| `scorecards[].prepUrl` | string \| null | no |  |
| `scorecards[].person` | object | no |  |
| `scorecards[].status` | string (`not_started`, `in_progress`, `submitted`) | no |  |
| `scorecards[].submittedAt` | string (date-time) \| null | no |  |
| `scorecards[].recommendation` | string \| null | no |  |
| `scorecards[].overall` | number \| null | no |  |
| `scorecards[].areas` | array of object | no |  |
| `scorecards[].feedback` | string \| null | no |  |
| `consolidated` | object | yes | Deterministic roll-up of all submitted interviewer cards. |
| `nextMoves` | array of object | yes |  |
| `nextMoves[].action` | string | no |  |
| `nextMoves[].label` | string | no |  |
| `feedbackCalls` | object | no | Where the two post-interview debriefs are up to on a client-flavour interview, phone and emailed channels combined. Both 'none' on internal interviews. 'awaiting_reply' means the emailed form is out and unanswered. |
| `feedbackCalls.client` | string (`none`, `started`, `awaiting_reply`, `logged`) | no |  |
| `feedbackCalls.candidate` | string (`none`, `started`, `awaiting_reply`, `logged`) | no |  |

### Meta

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