# 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: 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
- `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`

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

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

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

**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 moves the pipeline by the shared rules: failed on a client interview moves the application to rejected (no rejection email fires from the move); passed on an internal screen moves a maybe to yes, failed moves maybe or yes to no. Passed on a client interview and no_show never move the stage. |
| `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`

**Path parameters**

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

**Responses**

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

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

**Cancel interview**

Operation ID: `cancelInterview`

**Path parameters**

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

**Responses**

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

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

**Submit interview scorecard**

Submit a structured scorecard (per-criterion scores + overall recommendation + notes) for an interview. Persists the scorecard on the interview record. Requires interviews:write.

Operation ID: `submitScorecard`

**Path parameters**

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

**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: Interview } |
| `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`

**Path parameters**

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

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

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

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

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

**Path parameters**

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

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

**Path parameters**

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

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

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

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

**Path parameters**

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

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

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

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

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

**Path parameters**

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

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

### 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 |  |
