# Lovelio - full documentation Every public Lovelio doc, inlined. API version 7.2.0. The index-only version is https://lovelio.ai/llms.txt. Contents: - Lovelio for AI agents (https://lovelio.ai/docs/agents) - MCP quickstart (https://lovelio.ai/docs/agents/mcp-quickstart) - Sign up a customer (https://lovelio.ai/docs/agents/sign-up-a-customer) - Top 10 tasks (https://lovelio.ai/docs/agents/top-10-tasks) - Error handling (https://lovelio.ai/docs/agents/error-handling) - Webhooks (https://lovelio.ai/docs/agents/webhooks) - Build an integration (https://lovelio.ai/docs/agents/build-an-integration) - SDKs (https://lovelio.ai/docs/agents/sdks) - API reference (https://lovelio.ai/docs/api.md) --- # Index (llms.txt) # Lovelio > Lovelio is a recruitment automation platform (RAP) driven by AI agents over REST and MCP. An AI agent can sign up a customer, create a job, post it, receive and auto-assess applicants, and deliver a shortlist - with no UI involvement. Lovelio is not a traditional ATS. Its primary users are AI agents (Claude Code sessions, Claude Desktop, custom LLM agents) running on behalf of AI-first companies that want hiring to happen hands-off. Everything on this list is stable, public, and meant to be read by an LLM deciding how to integrate Lovelio. Every page below is server-rendered: fetch it and read it, no JavaScript needed. ## Get started - [Lovelio for AI agents (index)](https://lovelio.ai/docs/agents): 2-minute TL;DR of the hands-off hiring loop and how to drive it. - [MCP quickstart](https://lovelio.ai/docs/agents/mcp-quickstart): connect Claude Desktop or Claude Code, first tool call, JSON config block. - [Sign up a customer](https://lovelio.ai/docs/agents/sign-up-a-customer): programmatic account creation. Returns a live trial API key. - [Top 10 tasks](https://lovelio.ai/docs/agents/top-10-tasks): minimal runnable examples for the ten things agents do 95% of the time. - [Build an integration](https://lovelio.ai/docs/agents/build-an-integration): Lovelio Connect. Register, get a sandbox agency and API key in seconds, test webhooks live, submit to the integrations directory. ## Reference - [API reference (markdown)](https://lovelio.ai/docs/api.md): every endpoint with parameters, request bodies and responses, in one plain-text fetch. Start here. Narrow it with `?tag=Jobs` or get the list alone with `?index=1`. - [OpenAPI spec](https://lovelio.ai/api/v1/openapi.json): machine-readable source of truth, OpenAPI 3.1. Its `servers` block lists the three regional base URLs you actually call. - [Error handling](https://lovelio.ai/docs/agents/error-handling): error envelope, request IDs, retry rules, idempotency keys, rate limits. - [Webhooks](https://lovelio.ai/docs/agents/webhooks): subscribe, verify HMAC signatures, event catalogue. - [SDKs](https://lovelio.ai/docs/agents/sdks): single-file, zero-dependency TypeScript and Python clients. - [MCP overview](https://lovelio.ai/docs/mcp): what MCP is and what the 27+ Lovelio tools do. - [API changelog](https://lovelio.ai/docs/api/changelog): every version, including breaking changes. - [API reference (interactive)](https://lovelio.ai/docs/api): Scalar, for humans. It renders in the browser, so fetch `/docs/api.md` instead if you are not running JavaScript. ## Everything in one fetch - [llms-full.txt](https://lovelio.ai/llms-full.txt): every guide above plus the complete API reference, inlined. Any `/docs/agents` page also serves its markdown source at the same URL with `.md` on the end, for example https://lovelio.ai/docs/agents/webhooks.md. ## Calling the API Docs live on lovelio.ai. The API and MCP do not: every workspace belongs to a region, an API key only works on its own region's host, and lovelio.ai answers an API call with a JSON error naming all three. Base URLs: https://us.lovelio.ai/api/v1, https://eu.lovelio.ai/api/v1, https://anz.lovelio.ai/api/v1. MCP connect URL: /api/mcp. A workspace's exact host is shown in Settings > API keys. ## Optional - [Terms](https://lovelio.ai/terms) - [Privacy](https://lovelio.ai/privacy) --- # Lovelio for AI agents Source: https://lovelio.ai/docs/agents # Lovelio for AI agents Lovelio is a hiring system designed to be driven by an AI agent, not a human clicking a dashboard. You can sign up a customer, create a job, post it to a careers page, process applicants, and deliver a shortlist - all via HTTP or MCP, with no UI involvement. This page exists so any agent or LLM session can read it once and have everything needed to operate Lovelio for a customer. ## TL;DR (2 minutes) 1. Create an account: `POST /api/v1/accounts/signup` with an email. You get back a `trial_api_key` (prefix `sk_trial_`). 2. Put the key in an `Authorization: Bearer ...` header on every subsequent request. 3. Create a job from plain text: `POST /api/v1/jobs/from-description`. 4. Post it: `POST /api/v1/job-ads` and fetch `share_links` to distribute. 5. Applications arrive, get auto-parsed and auto-assessed. Poll `GET /api/v1/applications?status=longlisted` or subscribe to webhooks. 6. Move a candidate forward: `POST /api/v1/applications/{id}/transition`. That is the whole loop. Everything else on this site drills into one of those steps. ## Two ways to drive Lovelio **REST (recommended for agents running on your own infra):** standard HTTP, any language, OpenAPI spec at [`/api/v1/openapi.json`](/api/v1/openapi.json). See the full reference at [`/docs`](/docs). **MCP (recommended for any MCP-aware client - desktop app, editor, or command-line agent):** Lovelio exposes its toolset over MCP at `/api/mcp` on your region's app domain (us, eu, or anz.lovelio.ai). OAuth metadata at `/.well-known/oauth-protected-resource`. See [MCP quickstart](/docs/agents/mcp-quickstart). Both surfaces have the same capability. Pick the one that fits how the agent is being hosted. ## Pages - [MCP quickstart](/docs/agents/mcp-quickstart) - the server URL, the JSON config block, and your first tool call. - [Sign up a customer](/docs/agents/sign-up-a-customer) - programmatic account creation, in curl / JS / Python. - [Top 10 tasks](/docs/agents/top-10-tasks) - runnable examples for the ten things agents do 95% of the time. - [Error handling](/docs/agents/error-handling) - request IDs, retry rules, idempotency keys, rate limits. - [Webhooks](/docs/agents/webhooks) - subscribe, verify signatures, event catalogue. - [Build an integration](/docs/agents/build-an-integration) - Lovelio Connect: self-serve sandbox agency, test API key, live webhook logs, submit to the directory. - [SDKs](/docs/agents/sdks) - single-file, zero-dependency TypeScript and Python clients. ## Honest limits Things Lovelio does not do yet. Build around them or use another tool: - No native background-check integration. Use a partner (Checkr, etc.) and store the result as a timeline event via the API. - No in-app payroll/HRIS push on placement. You get a `placement.created` webhook; wire up your own downstream. - Free tier is rate-limited. If your agent is going to burst, ask for a paid key before you ship. - The MCP server requires OAuth for write operations. Read-only tool use works with the trial API key over HTTP; MCP writes need the OAuth flow. ## Canonical host All examples in this section use `https://us.lovelio.ai` as the host. Your workspace lives in one region - us, eu, or anz.lovelio.ai - and your key only works on that region's domain (it is the domain you sign in on). If you are developing locally, replace the host with `http://localhost:3005`. --- # MCP quickstart Source: https://lovelio.ai/docs/agents/mcp-quickstart # MCP quickstart Lovelio ships a Model Context Protocol server on each regional app domain at `/api/mcp`. Connect any MCP-aware client and run your recruiting desk with natural language. MCP is an open standard, so Lovelio is not tied to one AI company and needs no work on our side for a particular client. ## Pick your region first Every Lovelio workspace lives in exactly one region, and your API key and MCP connection only work on that region's domain: - `https://us.lovelio.ai/api/mcp` - Americas and rest of world - `https://eu.lovelio.ai/api/mcp` - UK, Ireland, and Europe - `https://anz.lovelio.ai/api/mcp` - Australia and New Zealand Your region is the domain you sign in on, and your exact connect URL is shown on the Settings -> API keys page. The examples below use the US domain - swap in yours. ## Before you connect You need a Lovelio API key. Two ways to get one: - **You already have a Lovelio account.** Go to [Settings -> API keys](https://us.lovelio.ai/settings/api-keys) and create a key. - **You are an agent signing up a new agency.** Call `POST https://us.lovelio.ai/v1/accounts/signup` with the agency details - on the regional domain where the agency should live, since signup creates the workspace in that region. The response includes a live trial API key. See [Sign up a customer](/docs/agents/sign-up-a-customer). ## Install The only thing any client needs is the server URL: ``` https://us.lovelio.ai/api/mcp ``` Clients that take a JSON config block want this. Most use the `mcpServers` key; a few name the wrapper differently: ```json { "mcpServers": { "lovelio": { "type": "http", "url": "https://us.lovelio.ai/api/mcp" } } } ``` Clients with a settings screen ask for the same URL under a heading like Connectors, Custom connector, or Add MCP server. Clients with a command line have their own add command - pass the URL and the HTTP transport. However you add it, the first call opens a browser page asking for your Lovelio API key. Paste it once; Lovelio encrypts it and issues OAuth tokens that your client refreshes automatically. Any client that supports the MCP spec 2025-06-18 or later completes OAuth on its own via the published metadata: - [`/.well-known/oauth-protected-resource`](https://us.lovelio.ai/.well-known/oauth-protected-resource) - [`/.well-known/oauth-authorization-server`](https://us.lovelio.ai/.well-known/oauth-authorization-server) ## How the OAuth flow works 1. Your client gets a 401 from the MCP endpoint and reads the discovery metadata above. 2. It registers itself at `POST /api/oauth/register` (dynamic client registration, public clients, PKCE S256 required) and gets a `client_id`. 3. It opens `/api/oauth/authorize` in a browser. A human pastes the Lovelio API key there, once. 4. The client exchanges the code at `POST /api/oauth/token` for an access token (1 hour) and a refresh token (30 days). Tokens are stored hashed; the API key is encrypted at rest. 5. Every MCP call carries the access token. The client refreshes it in the background. Disconnecting revokes at `POST /api/oauth/revoke`. ## First tool call Once connected, a test prompt: > List my 5 most recent jobs. The agent calls `list_jobs` and returns a table. Or an action: > Create a job for a Senior Backend Engineer, remote, GBP 110-140k base. The agent drafts the job with `create_job_from_description`, asks which client the job is for, and confirms it with you before publishing. ## What the tools can do The tools mirror the V1 REST API and the in-app action registry, so an agent runs the whole agency desk. Read tools are named `list_*` and `get_*`; write tools are named after the action they perform (`create_client`, `move_stage`, and so on). Every write goes through the same V1 route and permissions as the dashboard. The full REST surface is documented in the [OpenAPI reference](/docs). Grouped by area, with the scope each needs: - **Clients** (`clients:read` / `clients:write`) - `list_clients`, `get_client`, `create_client`, `update_client`, `add_client_contact`, `update_client_contact`, `add_client_dna_note`. Your own agency (the self-client used for internal hiring) is hidden from `list_clients`. - **Jobs** (`jobs:read` / `jobs:write`) - `list_jobs`, `get_job`, `get_job_summary`, `get_job_ranking`, `get_job_share_bundle`, `create_job`, `create_job_from_description` (+ the draft tools), `update_job`, `close_job`, `reopen_job`, and the job-ad / social-draft tools. Every job needs a `client_id` (get one from `list_clients` or `create_client`). - **Candidates** (`candidates:read` / `candidates:write`) - `list_candidates`, `get_candidate`, `create_candidate`, `update_candidate`, `search_candidates`. - **Applications** (`applications:read` / `applications:write`) - `list_applications`, `get_application`, `move_stage`, `reject_candidate`, `withdraw_application`, `add_note`, `log_offer`. - **Submissions** (`submissions:read` / `submissions:write`) - `list_submissions`, `get_submission`, `create_submission`, `chase_submission`, `answer_submission_question`. - **Interviews** (`interviews:read` / `interviews:write`) - `list_interviews`, `get_interview`, `schedule_interview`, `reschedule_interview`, `cancel_interview`, `update_interview_outcome`, `submit_scorecard`. - **Placements** (`placements:read` / `placements:write`) - `list_placements`, `get_placement`, `create_placement`, `mark_placement_status`. In the agency model the client owns the offer document; `log_offer` (in Applications) is the stage marker. - **Talent pools** (`talent_pools:write` / `candidates:write`) - `create_talent_pool`, `add_to_talent_pool`, `remove_from_talent_pool`. - **Comms** (`emails:write`) - `send_email`. Outbound email is off by default (see below). - **Account + admin** - `get_account_overview`, `get_transcription_usage`, `get_company_intelligence`, `refresh_company_intelligence`, `invite_team_member`, `create_webhook`, `search`, `list_activities`. - **Batch** - `batch` runs up to 100 of any of the above actions in one call, each with its own idempotency key. ## Sending email Outbound email is off by default on every new account, including agent-created trials. Tools that send email return a clear error until Lovelio enables outbound for your account. This is a safety rail, not a bug; contact hello@lovelio.ai to enable it. ## Rate limits Every tool call consumes one V1 API request against your key's rate limit (60 per minute on trial, 600 per minute on paid). The OAuth endpoints are rate limited per IP. See [error handling](/docs/agents/error-handling) for retry rules. ## Troubleshooting - **"Invalid API key" on the connect page.** Check the key in [Settings -> API keys](https://us.lovelio.ai/settings/api-keys). Trial keys expire; create a fresh one if yours has. - **401 "Invalid or expired access token".** Your refresh token has expired (30 days) or the connection was revoked. Reconnect: your client will open the authorize page again. - **OAuth loops.** Clear the cached credentials for your region's domain in your MCP client and reconnect. Local dev builds should point at `http://localhost:3005/api/mcp`. - **"Invalid or expired access token" right after connecting.** You are probably connected to the wrong region's URL - a connection made on one region's domain never works on another. Reconnect using the URL from Settings -> API keys. - **Rate limit 429.** Back off and retry after the `Retry-After` interval. --- # Sign up a customer Source: https://lovelio.ai/docs/agents/sign-up-a-customer # Sign up a customer Lovelio's signup endpoint is public - no auth, no demo, no sales call. You give it an email. It gives you back an account plus a 7-day trial API key that can read immediately; writes unlock when the customer verifies their email. ## Endpoint `POST /api/v1/accounts/signup` **Choose the region before you sign up.** The workspace is created in the region of the domain you call - `us.lovelio.ai` (Americas and rest of world), `eu.lovelio.ai` (UK, Ireland, Europe), or `anz.lovelio.ai` (Australia, New Zealand) - and it lives there permanently, along with the API key you get back. Pick the region where the agency's data should reside. Body: `{ "email": "founder@acme.com", "source": "claude-agent" }` Response (HTTP 202): ```json { "success": true, "data": { "account_id": "acc_01HXXX...", "status": "pending_verification", "trial_api_key": "sk_trial_abc123...", "email_verified": false, "message": "Your API key works now for reading data. Verify your email (check your inbox) to create and change data. The trial key lasts 7 days.", "enrichment_task_id": "task_01HXXX..." }, "meta": { "request_id": "req_abc123def456" }, "error": null } ``` The `trial_api_key` is returned **once**. Store it; you cannot retrieve it later. If lost, an account admin can mint a new key via `POST /api/v1/accounts/{id}/api-keys`. ## curl ```bash curl -X POST https://us.lovelio.ai/api/v1/accounts/signup \ -H "Content-Type: application/json" \ -d '{"email": "founder@acme.com", "source": "claude-agent"}' ``` ## JavaScript ```js const res = await fetch('https://us.lovelio.ai/api/v1/accounts/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'founder@acme.com', source: 'claude-agent' }), }); const { data } = await res.json(); const apiKey = data.trial_api_key; // Store apiKey. All subsequent calls use: Authorization: Bearer ``` ## Python ```python import requests r = requests.post( 'https://us.lovelio.ai/api/v1/accounts/signup', json={'email': 'founder@acme.com', 'source': 'claude-agent'}, timeout=30, ) r.raise_for_status() api_key = r.json()['data']['trial_api_key'] # Store api_key. Use it as Authorization: Bearer on every subsequent call. ``` ## What happens next 1. A verification email goes to the address. Until the customer clicks it, the trial key can read but not create or change data; clicking it unlocks writes. The trial itself lasts 7 days. 2. Lovelio runs an async enrichment task (`enrichment_task_id`) that crawls the domain, fetches branding colours + logo, and sets a sensible timezone. Poll `GET /api/v1/tasks/{id}` if you want to wait for it. 3. An `account.enriched` webhook fires when enrichment finishes (about 10-30 seconds). Subscribe via `POST /api/v1/webhooks` if you want push instead of poll. ## Duplicate handling If the email already has an account, the endpoint returns HTTP 202 with the existing `account_id` and a message telling the caller to check email for the verification link. It does not return a new API key. Build your flow so a duplicate signup is a no-op, not an error. If another account already claims the same email domain (e.g. someone at `acme.com` signed up before), the endpoint returns HTTP 409 with `error.code = "CONFLICT"` and a human-readable reason. Bounce the user to that account's admin or pick a different domain. ## Verifying the email programmatically The verification link in the email points to `/auth/verify-email?token=...`. For a fully headless onboarding flow, you cannot read the email on behalf of the user. Two options: - Let the human click the link themselves. The trial key can read in the meantime; writes wait for the click. - Use the invite flow instead: have an existing admin call `POST /api/v1/accounts/{id}/users` to create a member with a known password. That skips email verification entirely but requires you to already be signed in. ## Rate limits The signup endpoint is IP-rate-limited: 3 signups per hour and 5 per day per IP. If you hit 429, wait for the `Retry-After` header and try again. ## Hand this to your agent ``` Sign up a new Lovelio account for founder@acme.com, store the trial API key in an env var called LOVELIO_API_KEY, and print the account_id. ``` --- # Top 10 tasks Source: https://lovelio.ai/docs/agents/top-10-tasks # Top 10 tasks The ten things an agency consultant's agent does 95% of the time, with minimal runnable examples. Every example assumes you have set: ```bash export LOVELIO_API_KEY=sk_trial_... # Your region's app domain (us, eu, or anz.lovelio.ai) - keys only work on # the domain of the region they were created in. Shown in Settings > API keys. export LOVELIO_HOST=https://us.lovelio.ai ``` A note on two headers you will use throughout: - **`Authorization: Bearer $LOVELIO_API_KEY`** on every request. - **`Idempotency-Key`** on every `POST` that writes a record. Send a fresh UUID per logical action (`$(uuidgen)`); retrying with the same key returns the first result instead of creating a duplicate. The API rejects a write `POST` with no key. IDs are typed and prefixed: `cli_` client, `cct_` client contact, `job_` job, `jad_` job ad, `cnd_` candidate, `app_` application, `sub_` submission, `ivw_` interview, `pla_` placement, `tsk_` task, `whk_` webhook. Pass them back exactly as you received them. ## 1. Create a client Every job belongs to a client, so the client comes first. Give a name, or just a website and Lovelio enriches the rest (and starts the Client DNA first pass). ```bash curl -X POST $LOVELIO_HOST/api/v1/clients \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Northwind Payments", "website": "https://northwind.example", "industry": "Fintech" }' ``` Returns HTTP 201 with the client: ```json { "success": true, "data": { "id": "cli_...", "name": "Northwind Payments", "enrichment_status": "pending", "dna_status": "pending" }, "meta": { "request_id": "req_..." } } ``` Already have the client? Find it by name with `GET /api/v1/clients?search=northwind`. ## 2. Create a job for that client A job cannot exist without a client. Pass the `cli_` id from step 1 (for the agency's own internal hiring, pass the agency self-client's id). The job is created immediately and an async task fills in the spec, assessment criteria, and interview plan. ```bash curl -X POST $LOVELIO_HOST/api/v1/jobs \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "title": "Senior Backend Engineer", "client_id": "cli_...", "location": { "type": "remote", "country": "United Kingdom" }, "employment_type": "full_time", "compensation": { "base_min": 120000, "base_max": 140000, "currency": "GBP" } }' ``` Response (HTTP 202 with a `task_id`): ```json { "success": true, "data": { "task_id": "tsk_...", "job_id": "job_...", "poll_url": "/api/v1/tasks/tsk_..." }, "meta": { "request_id": "req_..." } } ``` The `job_id` is live immediately. Poll `GET /api/v1/tasks/{task_id}` to know when the AI spec is ready, or subscribe to `job.created` webhooks. Prefer natural language? `POST /api/v1/jobs/from-description` takes a plain-English brief (and an optional `client_id`) and streams a draft back over Server-Sent Events; you then confirm it with `POST /api/v1/jobs/from-description/{review_token}/confirm`. The structured `POST /api/v1/jobs` above is the simpler door for an agent. ## 3. Add a candidate and log their application Candidates belong to the agency, not the client. Create the candidate, then attach them to the job as an application. ```bash # a) create the candidate curl -X POST $LOVELIO_HOST/api/v1/candidates \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Priya Nair", "email": "priya@example.com", "resume_url": "https://example.com/priya-cv.pdf", "skills": ["Go", "Rust", "payments"] }' # -> { "data": { "id": "cnd_...", ... } } # b) log their application to the job curl -X POST $LOVELIO_HOST/api/v1/applications \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "candidate_id": "cnd_...", "job_id": "job_..." }' ``` The application returns HTTP 202 with an `app_` id and an assessment `task_id`. On arrival Lovelio runs automatically, no human action required: - CV text extraction (PDF/DOCX) and a structured AI parse. - An assessment against the job's criteria. - A suggested triage stage. Applications also arrive on their own via the careers page and embed widget. Subscribe to `application.received` and `application.scored` webhooks to observe either path. ## 4. Review the pipeline List applications for a job. Filter by `stage` to pull just the ones you care about. ```bash curl "$LOVELIO_HOST/api/v1/applications?job_id=job_...&stage=yes&limit=20" \ -H "Authorization: Bearer $LOVELIO_API_KEY" ``` Each row carries the candidate's contact details, parsed profile, and the AI `assessment` (with `job_fit_score` and per-criterion scores). Sort by `assessment.job_fit_score` descending to triage fast. The stages are the agency pipeline: `maybe` (awaiting triage, where new applications land), `yes` (shortlisted), `no` (parked), `submitted` (sent to the client), `client_interview`, `offer`, then the terminal `placed`, `rejected`, `withdrew`. Omit `stage` to see everything. ## 5. Move a candidate to a new stage ```bash curl -X POST $LOVELIO_HOST/api/v1/applications/app_.../stage \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "stage": "yes", "source": "api" }' ``` `stage` must be one of the canonical stages listed above. `source` must be one of `slack`, `whatsapp`, `web`, `api`. Response echoes the new state: ```json { "success": true, "data": { "application": { "id": "app_...", "stage": "yes", "stage_changed_at": "2026-07-21T09:30:00Z" } } } ``` Most stage moves happen on their own: sending a submission moves candidates to `submitted`, booking a client interview moves them to `client_interview`, logging an offer moves them to `offer`, and recording a placement moves them to `placed`. Use this endpoint for the manual moves (shortlisting, parking, rejecting). Add an `override_reason` if you are forcing a non-standard jump. ## 6. Submit a shortlist to the client The core agency move: send a shortlist of candidates to the client contact as a tokenised review link. This drafts a per-candidate summary (edit `summary` to override), emails the contact, and moves each candidate to `submitted`. ```bash curl -X POST $LOVELIO_HOST/api/v1/submissions \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "job_id": "job_...", "client_contact_id": "cct_...", "title": "Three for the Backend Engineer role", "items": [ { "application_id": "app_...", "summary": "Led the payments rewrite at Northwind." }, { "application_id": "app_..." } ] }' ``` Returns HTTP 201 with the `sub_` id, an `email_sent` flag, and a client-facing `url`. Anyone with that link can request an interview, pass with a reason, or ask a question; each response updates the pipeline and notifies you. Track responses with `GET /api/v1/submissions?job_id=job_...`. ## 7. Schedule a client interview When the client asks to meet a candidate, book it. Set `flavour: "client"` so the application moves to `client_interview` automatically; leave it `internal` for your own screens. ```bash curl -X POST $LOVELIO_HOST/api/v1/interviews \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "application_id": "app_...", "flavour": "client", "format": "video", "scheduled_at": "2026-08-03T14:00:00Z", "duration_minutes": 45, "interviewer_ids": ["usr_..."], "submission_id": "sub_..." }' ``` Returns HTTP 201 with the `ivw_` id. Video interviews get a Lovelio call room and the invitation email fires when a `scheduled_at` is set. After the interview, drop feedback with `POST /api/v1/interviews/{id}/scorecard` (body: `recommendation` one of `strong_hire`/`hire`/`no_hire`/`strong_no_hire`, plus optional `scores` and `notes`). ## 8. Log an offer Offers are the client's to make and sign in the agency model, so there is no offer-letter endpoint. `log_offer` is a lightweight marker that records the client made an offer and moves the application to the `offer` stage. It has no dedicated route, so run it through the batch endpoint (no top-level idempotency header; each op carries its own key): ```bash curl -X POST $LOVELIO_HOST/api/v1/batch \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "operations": [ { "op": "log_offer", "idempotency_key": "'"$(uuidgen)"'", "payload": { "application_id": "app_...", "offered_salary": 135000, "offered_currency": "GBP" } } ] }' ``` Returns HTTP 200 with a per-op status array. When the offer is accepted, record it as a placement (next). ## 9. Record the placement (the win) The placement is the record of the win: candidate, client, job, salary, fee, start date, and the guarantee. Creating one against an application moves it to `placed`. The fee percent and guarantee default off the client's standard terms when you omit them. ```bash curl -X POST $LOVELIO_HOST/api/v1/placements \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "application_id": "app_...", "salary": 135000, "currency": "GBP", "fee_type": "percent", "fee_percent": 20, "start_date": "2026-09-01" }' ``` Returns HTTP 201 with the `pla_` id and the computed `fee.amount`. If you do not have an application to anchor to, pass `candidate_id` + `client_id` instead. Review the trophy cabinet with `GET /api/v1/placements`. ## 10. Pull the numbers Three read-only analytics endpoints, no charts, just numbers an agent can forward to Slack or paste into a summary. ```bash # Headline KPIs over a window (default 30 days) curl "$LOVELIO_HOST/api/v1/analytics/metrics?days=7" \ -H "Authorization: Bearer $LOVELIO_API_KEY" ``` `metrics` returns `applications_received`, `placements_made`, `interviews_conducted`, `offers_extended`, `offer_acceptance_rate`, `interview_to_hire_ratio`, and a `source_breakdown`. For the funnel with stage-by-stage conversion use `GET /api/v1/analytics/pipeline` (optionally `?job_id=job_...`); for a live snapshot of active jobs, upcoming interviews, and recent placements use `GET /api/v1/analytics/dashboard`. ## Hand this to your agent Paste into an agent session with Lovelio MCP connected or the API key in env: ``` Create a client called Northwind Payments (website northwind.example), then create a Senior Backend Engineer job for them: remote in the UK, GBP 120-140k. Add candidate Priya Nair (priya@example.com) and log her application. Once her assessment is back, shortlist her, and if she scores well, submit her plus the top two others to the client contact as a shortlist. Post a summary to Slack when done. ``` --- # Error handling Source: https://lovelio.ai/docs/agents/error-handling # Error handling Every Lovelio API response - success or failure - carries a consistent envelope. ## Success ```json { "success": true, "data": { ... }, "meta": { "request_id": "req_abc123def456" }, "error": null } ``` ## Failure ```json { "success": false, "data": null, "meta": { "request_id": "req_abc123def456" }, "error": { "type": "validation", "code": "VALIDATION_ERROR", "message": "email is required.", "field": "email", "docs": "https://lovelio.ai/docs/agents/error-handling#validation" } } ``` ## Always log request_id Every response includes `meta.request_id`. Log it on every API call you make. If you file a support ticket or need the team to trace an issue, the request ID is the fastest path from your log line to our traces in Sentry + Inngest. HTTP header form: `X-Request-ID`. Both are set on every response. ## Error types | `error.type` | HTTP | When it happens | What to do | |---|---|---|---| | `validation` | 400 | Missing or malformed field | Fix and retry. `error.field` points at the offender. | | `unauthenticated` | 401 | Missing or invalid API key | Check `Authorization: Bearer sk_...` header. | | `permission_denied` | 403 | API key scope does not cover this action | Use an admin-scoped key or narrow the request. | | `not_found` | 404 | Resource does not exist (or RLS hid it) | The ID is wrong or belongs to another account. | | `conflict` | 409 | Duplicate resource or state mismatch | Handle as a no-op or pick a different identifier. | | `rate_limited` | 429 | Too many requests | Read `Retry-After`, back off, retry. | | `internal` | 500 | Our fault | Retry with backoff. If persistent, file with the `request_id`. | | `service_unavailable` | 503 | Upstream down (AI provider, etc.) | Retry after 30s. | ## Idempotency Every mutating endpoint accepts an `Idempotency-Key` header. Use a UUID per logical operation. Retries with the same key return the original response - no duplicate resources. ```bash curl -X POST $LOVELIO_HOST/api/v1/applications/app_01HXXX/transition \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -d '{ "to_stage": "shortlisted" }' ``` Keys are scoped per API key and valid for 24 hours. After that the key is forgotten and a retry creates a fresh operation. ## Retry rules Safe to retry: any 5xx, any 429, any network error. Safe to retry **only with an idempotency key** on mutating calls: POST, PATCH, DELETE. Without the key, a retry may create a duplicate. Never retry: 4xx (except 429). They will not succeed no matter how many times you try. Exponential backoff starting at 1 second, doubling to a max of 60. Stop after 5 attempts. ## Rate limits Every response includes: ``` X-RateLimit-Limit: 600 X-RateLimit-Remaining: 598 X-RateLimit-Reset: 1730000000 ``` Free tier: 100 requests / minute per API key. Paid tiers lift this to 600+ depending on plan. Burst allowance sits on top: you can spike to 2x the limit for up to 10 seconds before getting throttled. When you hit 429, `Retry-After` is the absolute minimum wait in seconds. Respect it. Aggressive clients get IP-blocked. ## Bulk operations If you find yourself making >50 calls in a row for the same type of operation, switch to `POST /api/v1/batch`. One call, up to 100 operations, all counted as a single rate-limit unit. ```bash curl -X POST $LOVELIO_HOST/api/v1/batch \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "operations": [ { "method": "POST", "path": "/applications/app_1/transition", "body": { "to_stage": "rejected" } }, { "method": "POST", "path": "/applications/app_2/transition", "body": { "to_stage": "shortlisted" } } ] }' ``` Batch is all-or-nothing by default. Set `atomic: false` if you want partial success semantics. ## Debugging checklist When a call fails and the error message is not obvious: 1. Check `error.docs` - points to the exact docs section for the error. 2. Copy the `request_id`. Paste into any support message. 3. Verify the key is live: `GET /api/v1/accounts/me`. If that returns 200, your auth is fine; the issue is in the specific endpoint. 4. Verify the resource exists and belongs to the authed account: `GET /api/v1/{resource}/{id}`. A 404 here means wrong account or deleted resource. 5. For async tasks, check the task state: `GET /api/v1/tasks/{task_id}`. Error details live there, not on the originating call. --- # Webhooks Source: https://lovelio.ai/docs/agents/webhooks # Webhooks Lovelio fires webhooks on every meaningful state change. Prefer webhooks to polling whenever possible - they are cheaper for both sides and latency is sub-second. ## Subscribe ```bash curl -X POST $LOVELIO_HOST/api/v1/webhooks \ -H "Authorization: Bearer $LOVELIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "url": "https://your-app.example.com/lovelio-webhook", "events": ["application.received", "application.scored", "placement.created"] }' ``` Response: ```json { "success": true, "data": { "id": "whk_01HXXX...", "url": "https://your-app.example.com/lovelio-webhook", "events": ["application.received", "application.scored", "placement.created"], "status": "active" }, "secret": "whsec_abc123..." } ``` The `secret` is returned **once**. Store it. It is the HMAC signing key for verifying deliveries. Use `["*"]` to subscribe to every event. ## Verify the signature Every delivery includes three headers: ``` X-Lovelio-Event: application.scored X-Lovelio-Timestamp: 1730000000 X-Lovelio-Signature: sha256= ``` Signature formula: `HMAC-SHA256(secret, timestamp + "." + raw_body)`, hex-encoded, prefixed with `sha256=`. ### Node.js verification ```js import crypto from 'node:crypto'; function verify(rawBody, signatureHeader, timestampHeader, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(`${timestampHeader}.${rawBody}`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader))) { throw new Error('Bad signature'); } // Reject replay attacks - timestamp older than 5 minutes. if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > 300) { throw new Error('Stale webhook'); } } ``` ### Python verification ```python import hmac, hashlib, time def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> None: expected = 'sha256=' + hmac.new( secret.encode(), f"{timestamp}.{raw_body.decode()}".encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, signature): raise ValueError('Bad signature') if abs(time.time() - int(timestamp)) > 300: raise ValueError('Stale webhook') ``` ## Delivery payload Every delivery is a JSON body of the same shape: ```json { "event": "placement.created", "event_id": "evt_abc123...", "account_id": "acc_...", "created_at": "2026-07-06T12:00:00.000Z", "data": { "...event-specific fields..." } } ``` Deduplicate on `event_id`. ## Event catalogue Every event below is really emitted by Lovelio. Subscribe to any subset, or `*` for all. Fetch this catalogue programmatically from `GET /api/v1/webhooks/events`. The [SDKs](/docs/agents/sdks) ship a ready-made signature verifier. | Event | Fires when | |---|---| | `account.enriched` | Async account enrichment finishes | | `candidate.enriched` | Async candidate enrichment finishes | | `application.received` | A candidate is added to a job | | `application.scored` | AI assessment completes | | `application.stage_changed` | An application moves pipeline stage | | `application.status_changed` | An application is rejected or withdrawn | | `job.created` | A job is created and its draft pipeline finishes | | `job_ad.published` | A job ad goes live | | `interview.scheduled` | An interview is booked | | `interview.cancelled` | An interview is cancelled | | `interview.outcome_recorded` | An interview outcome or scorecard is recorded | | `submission.created` | A shortlist is sent to a client contact | | `submission.responded` | A client requests an interview, passes, or asks a question | | `placement.created` | A placement is logged (the win) | | `placement.status_changed` | A placement's guarantee status changes | | `email.sent` | A tracked email is sent | | `email.scheduled` | An email is scheduled to send later | | `email.cancelled` | A scheduled email is cancelled | | `reference.received` | A candidate submits referee details | | `spec.sent` | A spec (float) is sent: an anonymous candidate profile to a client with no job | | `spec.viewed` | A client contact opens a spec profile page for the first time | | `spec.responded` | A client responds to a spec (interested, a question, or not right now) | | `spec.converted` | An interested spec becomes a real job + application | | `marketplace.intro.received` | Another agency submits a candidate to your marketplace listing, or requests your shared candidate | | `marketplace.deal.formed` | A marketplace intro is accepted - the split deal forms and identity reveals (fires for both agencies) | | `marketplace.deal.placed` | A placement lands on a marketplace deal - the shared money snapshot is stamped (fires for both agencies) | > Offer events (`offer.*`) were retired when placements replaced the offer machinery. In agency recruitment the client makes and signs the offer with the candidate, so the win is recorded as a placement. Subscribe to `placement.created` and `placement.status_changed` instead. ## Delivery guarantees - At-least-once delivery. Deduplicate on `event_id`. - Exponential backoff on failure across six attempts, then the delivery is dead-lettered (visible via `GET /api/v1/webhooks/{id}/deliveries`). - A 2xx response within 10 seconds counts as success. Anything else is a failure. - Replay a specific delivery with `POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay`. ## Rotating a secret ```bash curl -X POST $LOVELIO_HOST/api/v1/webhooks/whk_01HXXX/rotate-secret \ -H "Authorization: Bearer $LOVELIO_API_KEY" ``` Returns a new `whsec_...`. ## Deleting a subscription ```bash curl -X DELETE $LOVELIO_HOST/api/v1/webhooks/whk_01HXXX \ -H "Authorization: Bearer $LOVELIO_API_KEY" ``` ## Hand this to your agent ``` Subscribe a webhook at https://my-worker.example.com/lovelio to the events application.scored and placement.created. Store the returned secret in our secrets manager under LOVELIO_WEBHOOK_SECRET. Show me the subscription ID when done. ``` --- # Build an integration Source: https://lovelio.ai/docs/agents/build-an-integration # Build an integration with Lovelio Connect Lovelio Connect is the self-serve developer platform. You register, create an integration, get a sandbox agency with a working API key in about 20 seconds, build and test against the real API, and submit for review - no calls, no forms, no waiting on us. **Building one integration for one agency - your own, or a client of yours? You do not need any of this.** Ask them for an API key from **Settings > API keys** in their Lovelio account and call the [V1 API](/docs/api) directly with it. Connect is the route when you want OTHER agencies to find and connect to your app from the public directory: that is what the OAuth client, the certification run and the review are for. ## 1. Register Go to [lovelio.ai/developers/portal](https://lovelio.ai/developers/portal) and sign in with your work email. We email you a six-digit code; entering it proves you own a mailbox on your company's domain. The first person from your company founds the workspace and names it - teammates who sign in later with the same domain land in it automatically. Free email providers are not accepted. ## 2. Create an integration and its sandbox Click **Create integration**, name it, then **Create sandbox**. About 20 seconds later you have: - A complete Lovelio agency seeded with realistic data: 5 clients, 6 jobs, 18 candidates with full CVs, a pipeline mid-flight across all 9 stages, interviews, submissions and placements. - One API key with full scopes, shown exactly once. Copy it immediately. The key is 48 characters, starting `sk_test_`. After you close that box the portal shows the first 20 characters with the rest masked out: an identifier for the key, never the key itself. We store only a hash, so nobody at Lovelio can read it back to you. If you lose it, **Reset sandbox** issues a new one (and wipes the sandbox data). The sandbox is a real Lovelio tenant. Every endpoint, webhook, permission check and rate limit behaves exactly as it will for a paying agency. Two guardrails are permanent: a sandbox can never email a real person, and it never appears in the marketplace. ```bash curl https://us.lovelio.ai/api/v1/jobs \ -H "Authorization: Bearer sk_test_..." ``` Your sandbox does not expire. **Reset sandbox** wipes it and provisions a fresh agency with a new key whenever you want a clean slate. ## 3. Build Everything you need is public: - **API reference**: [/docs/api.md](/docs/api.md) - every endpoint with its parameters, request body and responses, as plain markdown in one fetch. Narrow it with `?tag=Jobs`, or get the endpoint list alone with `?index=1`. [/docs/api](/docs/api) is the same content rendered for humans; it draws in the browser, so fetch the `.md` if you are not running JavaScript. - **Error handling**: [/docs/agents/error-handling](/docs/agents/error-handling) - the envelope, request IDs, retries, idempotency keys, rate-limit headers. - **Webhooks**: [/docs/agents/webhooks](/docs/agents/webhooks) - subscribe with `POST /v1/webhooks`, verify the HMAC-SHA256 signature, and read the event catalogue at `GET /v1/webhooks/events`. - **SDKs**: [/docs/agents/sdks](/docs/agents/sdks) - single-file TypeScript and Python clients. - **AI agents**: point any MCP client at the Lovelio MCP server ([/docs/mcp](/docs/mcp)) with your sandbox key and let it drive the API for you. The portal shows live webhook activity for your sandbox: every subscription, every delivery, response codes and retries. Create a subscription, trigger an event (move a candidate's stage, create a job), and watch it arrive. ## 4. Let agencies connect your app Lovelio has two developer credentials and they do different jobs. Never swap one for the other: | Credential | What it is | What it opens | | --- | --- | --- | | API key (`sk_test_...`) | A Bearer token, minted with your sandbox | Your own sandbox agency, and nothing else | | Client ID + client secret (`lc_client_...`, `lc_secret_...`) | An OAuth 2.0 confidential client, created in the portal | Any agency that connects to your app, at the scopes they granted | Writing a test script against your own sandbox? The API key is all you need - one header, no OAuth. Shipping to real agencies? You need the OAuth client as well: an API key belongs to one tenant and can never be issued to anyone else. There is no client ID until you click **Create OAuth client** - a fresh integration has an API key and no client at all. Your app ships against the connect flow, not against pasted API keys. In the portal, open your integration and click **Create OAuth client**. You get: - A **client ID** (`lc_client_...`) you can publish. - A **client secret** (`lc_secret_...`), shown exactly once, inside a ready-made `.env` block with your other three values. Keep it server-side. **Rotate** it from the portal if it ever leaks; the old secret stops working the moment the new one appears. Then give the two ends of the round trip, which the portal asks for together because they are the same kind of thing - a route on your own server, not a page anyone looks at: - **Your connect endpoint.** Where Lovelio hands off when an agency clicks Connect. It redirects them straight to the Lovelio approve screen, carrying your own state and PKCE challenge, which is why Lovelio cannot build that URL for you. The agency sees your product's name on the approve screen and nothing of your website. - **Your redirect URIs** (https only; http is allowed on localhost while you build). Where Lovelio sends the approved connection so your server can pick up its tokens. `/api/oauth/authorize` refuses any `redirect_uri` that is not registered here. Send the agency back into Lovelio when you are done, and the whole thing reads as two clicks inside the app they were already in. Certification fetches both and fails on a dead one, so a typo costs you a red run rather than a failed connection in front of a real agency. You do not tick scopes off a list. The **Describe what you built** step takes one sentence on what your product does, then asks whether it writes anything back into Lovelio and whether it shows or calculates money. Lovelio works out the smallest access set that supports that answer and switches it on for you. Saying it only reads bars every write outright. The picker shows what you have in plain English - "Placements: read" - with the technical scope names one disclosure away, and only the areas your integration uses; everything else is behind **Ask for something else**. Take access away for free. Adding something your sentence did not call for costs a written reason, which the reviewer and every connecting agency both read. Access locks while your submission is in review, and widening it after approval forces re-review (see below). **You do not have to write this flow yourself, or even read it.** The connect flow step in the portal leads with **Copy the setup prompt**: one self-contained prompt, with your real values and the reference implementation in it, that an AI coding tool turns into both routes in your own stack. The prompt never carries your client secret - you paste that into your env file yourself. Prefer the source? Behind the **For developers** disclosure the portal prints the flow with your own client ID, redirect URI and region already in it: a connect route and a callback route, in Node, Python, PHP, Go and cURL, with **Copy code** and **Download both files** on each. The four environment values the code reads (`LOVELIO_API_URL`, `LOVELIO_CLIENT_ID`, `LOVELIO_CLIENT_SECRET`, `LOVELIO_REDIRECT_URI`) are printed as one block at the moment the secret is issued, which is the only moment the secret exists in a browser. The flow is standard OAuth 2.0 authorization code with PKCE (S256 only, a plain or absent challenge is refused), plus your client secret at the token endpoint: 1. Your connect route makes a random `code_verifier`, stores it against that browser, and redirects the agency admin to `/api/oauth/authorize` on the agency's region domain (us, eu or anz.lovelio.ai) with `response_type=code`, `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge` (base64url SHA-256 of the verifier) and `code_challenge_method=S256`. 2. They sign in if needed, see "**Your app** wants access to **their agency**" with the scope list, and approve. Connecting takes an admin - other roles are asked to pass the link on. 3. Your redirect URI receives `?code=...&state=...`. Check the state, then exchange the code at `POST /api/oauth/token` with `grant_type=authorization_code`, your `client_id`, `client_secret`, the `code_verifier` you stored, and the same `redirect_uri`. The verifier is what proves the code came back to the server that started the connection, so a stolen code on its own is worth nothing. 4. You get an access token (`lc_at_...`, one hour) and a refresh token (`lc_rt_...`). Call the V1 API with `Authorization: Bearer lc_at_...` - the token carries exactly the scopes the agency granted, enforced on every request. Read `scope` on the response rather than assuming: a granted set can be narrower than the one you asked for. Refresh with `grant_type=refresh_token` plus your client credentials; refresh tokens rotate on every use and the window slides while the integration stays active. The same endpoints, parameters and scopes are declared in the OpenAPI spec at [/docs/api](/docs/api) under the `appOAuth2` security scheme, so a generated client can drive the connection without reading this page. Webhook subscriptions your app creates with its token belong to the connection: the agency sees them under Settings > Connected apps, and disconnecting disables them and revokes every token immediately. Test the whole flow before review: the consent screen works against your own sandbox from day one, no approval needed. Click **Open sandbox** in the portal to sign in to the sandbox agency, then open your authorize URL in the same browser and approve. Once the app is approved, any Lovelio agency can connect to it. ## 5. Run certification Before a human reviews your integration, the certification runner has to pass. Click **Run certification** in the portal: it runs the automated checks against your own sandbox in about a minute and shows you a step-by-step report. What it checks: - **Auth**: requests without a key and with a made-up key are refused; your sandbox key works. - **Error handling**: errors carry the standard envelope (code, message, request ID) and writes demand an `Idempotency-Key`. - **Pagination**: cursor pagination pages cleanly with no repeated rows. - **Webhook signing**: it creates a subscription, fires the signed test ping, and verifies the HMAC-SHA256 signature end to end, then removes the subscription. - **Your two links**: the connect endpoint and every redirect URI are fetched. A `404` or a domain that does not resolve fails the run - that is the typo nobody else catches, because the runner consents through the sandbox door and the directory's Connect button is not exercised until a real agency clicks it. A `401`, `403` or redirect passes: a sign-in wall on your connect endpoint is normal. A localhost URI is skipped, not failed - Lovelio cannot reach your machine. - **The connect flow**: your OAuth client exists and requests scopes, the token endpoint refuses a wrong client secret, the app connects to your sandbox through the real consent path, a granted scope works and an ungranted one is refused. If certification created the connection it removes it again; if you already connected your app into your sandbox, that connection is reused and left alone. A red run tells you exactly which step failed and why - fix it and run again. A green run goes **stale** if you reset the sandbox, recreate the OAuth client, change the scope set, or change either of your two links afterwards, because what was proven is no longer what would ship. Run it again after any of those. ## 6. Submit for review When certification is green, fill in the listing (description, category, support link, privacy policy link) and click **Submit for review**. Your connect endpoint is not asked for here - it was set with your redirect URIs in step 4. Submitting needs a working sandbox and a green, current certification run - the report rides along to the reviewer. We review by hand and reply by email: - **Approved** - your integration goes live in the [Lovelio integrations directory](https://lovelio.ai/integrations) straight away. - **Changes requested** - the note says exactly what to fix. Editing reopens; fix and resubmit. - **Rejected** - the note says why. ## 7. After approval A few rules keep the directory honest without slowing you down: - **Listing copy publishes without re-review.** Name, description, category, tagline and links stay editable in the portal while the app is approved, and the public directory picks up a save straight away. - **Adding scopes forces re-review; removing them is free.** The scope set is what review approved, so asking for MORE takes the app off the directory and back to draft - run certification again and resubmit to relist. Narrowing the set never costs review: the app stays approved and listed. Agencies that already connected keep the scopes they granted either way; only new connections wait for a re-approval. The API asks for `confirm_re_review: true` on a widening so it never happens by accident. - **New apps start in a limited rollout.** Approval sets a cap on active connections, sized by how much access the app requests (read-only apps get a high cap, apps touching email content, documents or marketplace writes a low one). Past the cap, an agency trying to connect sees an "app at capacity" page instead of the consent screen. When you are close, email [hello@lovelio.ai](mailto:hello@lovelio.ai) and we raise or remove it - your approval email states your starting cap. - **Usage is visible in the portal.** The Usage card on your integration shows active connections against the cap, plus the last 30 days of API calls and webhook deliveries across every region your app lives in, sandbox included. - **Lovelio can suspend an app.** If an integration misbehaves, suspension takes it off the directory and stops its credentials and webhooks everywhere at once - existing connections stop working until it is resolved. Unsuspending restores the connections exactly as they were. ## Questions Email [hello@lovelio.ai](mailto:hello@lovelio.ai). If your integration needs something the API does not cover yet, say so in the submission description - API gaps reported by partners get prioritised. --- # SDKs Source: https://lovelio.ai/docs/agents/sdks # SDKs Two official clients, one file each, zero dependencies. Download the file, drop it into your project, done. No package manager required - they are plain source you own. | Language | Download | Requires | |---|---|---| | TypeScript / JavaScript | [lovelio.ts](/sdk/lovelio.ts) | Node 18+, Deno, Bun, browsers, or edge workers | | Python | [lovelio.py](/sdk/lovelio.py) | Python 3.9+, standard library only | Both clients handle the whole API contract for you: - Bearer auth and the response envelope (data unwrapping, typed errors with `code`, `status`, `request_id`) - Auto-generated `Idempotency-Key` on every POST (the API requires one on mutating POSTs) - Cursor pagination, including an iterator that follows `next_cursor` to exhaustion - Retry with backoff on 429 and 5xx, honouring `Retry-After` - Async task polling (`waitForTask` / `wait_for_task`) for 202 responses - Webhook signature verification (HMAC-SHA256, constant-time compare, replay-window check) ## TypeScript ```ts import { Lovelio, verifyWebhookSignature } from './lovelio' const client = new Lovelio({ apiKey: process.env.LOVELIO_API_KEY! }) // Lists return { data, meta }; iterate() pages for you const { data: active } = await client.jobs.list({ status: 'active' }) for await (const candidate of client.candidates.iterate()) { console.log(candidate.id) } // Async creates return a task handle - wait for the pipeline to finish const created = await client.jobs.create({ title: 'Senior Property Manager', client_id: 'cli_...', }) await client.waitForTask(created.task_id) // Actions run through batch await client.batch([ { op: 'add_note', payload: { candidate_id: 'cnd_...', note: 'Called, keen.' } }, ]) // In your webhook handler - verify against the RAW body const ok = await verifyWebhookSignature({ payload: rawBody, signature: req.headers['x-lovelio-signature'], timestamp: req.headers['x-lovelio-timestamp'], secret: process.env.LOVELIO_WEBHOOK_SECRET!, }) ``` ## Python ```python from lovelio import Lovelio, verify_webhook_signature client = Lovelio(api_key=os.environ["LOVELIO_API_KEY"]) active = client.jobs.list(status="active")["data"] for candidate in client.candidates.iterate(): print(candidate["id"]) created = client.jobs.create(title="Senior Property Manager", client_id="cli_...") client.wait_for_task(created["task_id"]) client.batch([ {"op": "add_note", "payload": {"candidate_id": "cnd_...", "note": "Called, keen."}}, ]) ok = verify_webhook_signature(raw_body, signature_header, timestamp_header, webhook_secret) ``` ## Errors Both clients raise one exception type on any non-2xx: `LovelioError` with `code` (the API's stable error code, e.g. `VALIDATION_ERROR`, `INSUFFICIENT_SCOPE`), `status`, `message`, and `request_id` for support. 429 and 5xx are retried automatically before the error reaches you. ## Hand this to your agent ``` Download https://lovelio.ai/sdk/lovelio.ts into the project, then use it with our LOVELIO_API_KEY to list active jobs, add today's shortlisted candidates to each, and print the ids it created. Wait for any task the API returns. ``` --- # 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 . 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 ### System Health and status - `GET /health` - Health check - `POST /health` - Health check (POST) ### Accounts Account and API key management - `POST /accounts/signup` - Create account - `POST /accounts/verify-email` - Verify account email - `GET /accounts/me` - Get current account - `GET /accounts/{id}` - Get account - `PATCH /accounts/{id}` - Update account - `GET /accounts/{id}/users` - List users - `POST /accounts/{id}/users` - Invite a user - `GET /accounts/{id}/users/{userId}` - Get user - `PATCH /accounts/{id}/users/{userId}` - Update user - `DELETE /accounts/{id}/users/{userId}` - Delete user - `GET /accounts/{id}/teams` - List teams - `POST /accounts/{id}/teams` - Create team - `GET /accounts/{id}/teams/{teamId}` - Get team - `PATCH /accounts/{id}/teams/{teamId}` - Update team - `DELETE /accounts/{id}/teams/{teamId}` - Delete team - `GET /accounts/{id}/api-keys` - List API keys - `POST /accounts/{id}/api-keys` - Create API key - `DELETE /accounts/{id}/api-keys/{keyId}` - Revoke API key - `GET /accounts/{id}/sso` - List SSO connections - `POST /accounts/{id}/sso` - Create SSO connection - `DELETE /accounts/{id}/sso/{connectionId}` - Remove SSO connection - `GET /accounts/{id}/transcription-usage` - Get video transcription usage - `POST /accounts/{id}/upgrade` - Activate account outside Stripe Checkout - `GET /accounts/{id}/intake-slo` - Get intake pipeline SLO - `GET /accounts/{id}/intelligence` - Get company intelligence profile - `POST /accounts/{id}/intelligence/refresh` - Refresh company intelligence ### Jobs Job requisition management with AI enrichment - `GET /jobs` - List jobs - `POST /jobs` - Create job (async) - `POST /jobs/from-description` - Draft a job from a natural-language description (streaming) - `POST /jobs/from-description/{review_token}/confirm` - Confirm a staged draft and create the job (async) - `GET /jobs/from-description/{review_token}` - Get the current state of a staged job draft - `PATCH /jobs/from-description/{review_token}` - Update a staged job draft (fill gaps) - `POST /jobs/from-description/{review_token}/revise` - Apply a free-text revision to a staged draft - `GET /jobs/{id}/ad` - Get the latest job ad - `PATCH /jobs/{id}/ad` - Update a job ad - `POST /jobs/{id}/ad/rewrite` - AI-rewrite a job ad - `GET /jobs/{id}/social-draft` - Get a job's LinkedIn + X post drafts - `PATCH /jobs/{id}/social-draft` - Update a job's social post text - `GET /jobs/{id}` - Get job - `PATCH /jobs/{id}` - Update job - `DELETE /jobs/{id}` - Delete job - `POST /jobs/{id}/close` - Close job - `POST /jobs/{id}/reopen` - Reopen job - `GET /jobs/{id}/summary` - AI job summary - `GET /jobs/{id}/ranking` - Stack-ranked interviewed candidates for a job - `GET /jobs/{id}/share-bundle` - Get share-ready content bundle for a job ### Job Ads Public job postings and career pages - `GET /job-ads` - List job ads - `POST /job-ads` - Create job ad - `GET /job-ads/{id}` - Get job ad - `DELETE /job-ads/{id}` - Delete job ad - `POST /job-ads/{id}/expire` - Expire job ad ### Candidates Candidate profiles and talent database - `GET /candidates` - List candidates - `POST /candidates` - Create candidate - `GET /candidates/{id}` - Get candidate - `PATCH /candidates/{id}` - Update candidate - `DELETE /candidates/{id}` - Delete candidate - `DELETE /candidates/{id}/gdpr-delete` - GDPR delete candidate - `POST /candidates/search` - Search candidates - `GET /candidates/duplicates` - Find duplicate candidates - `POST /candidates/merge` - Merge candidates - `POST /candidates/from-cv` - Upload a CV and stage a candidate draft - `GET /candidates/from-description/{review_token}` - Get a staged candidate draft - `PATCH /candidates/from-description/{review_token}` - Patch fields on a staged candidate draft - `POST /candidates/from-description/{review_token}/confirm` - Create a candidate from a staged draft ### Candidate Imports Bulk CV import - migrate thousands of CVs into the talent bank - `POST /candidate-imports` - Start a bulk CV import - `GET /candidate-imports/{id}` - Get import report - `POST /candidate-imports/{id}/files` - Request upload slots - `POST /candidate-imports/{id}/finalize` - Confirm uploads ### Applications Application pipeline with AI assessment - `GET /applications` - List applications - `POST /applications` - Create application (async) - `GET /applications/{id}` - Get application - `PATCH /applications/{id}` - Update application - `POST /applications/bulk-update` - Bulk update applications - `POST /phone-screens/from-description` - Stage a phone-screen log - `GET /phone-screens/from-description/{review_token}` - Get a staged phone-screen draft - `PATCH /phone-screens/from-description/{review_token}` - Patch fields on a staged phone-screen draft - `POST /phone-screens/from-description/{review_token}/confirm` - Confirm a phone-screen log - `POST /applications/{id}/stage` - Transition application stage ### 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 ### Submissions Client-facing shortlists with a tokenised review link and a live feedback loop - `GET /submissions` - List submissions - `POST /submissions` - Create and send a submission - `GET /submissions/{id}` - Get submission ### Placements The record of the win: fee, start date, guarantee lifecycle - `GET /placements` - List placements - `POST /placements` - Log placement - `GET /placements/{id}` - Get placement - `PATCH /placements/{id}` - Update placement ### Quotas Quota plan, periods and the recognition board: credit-share money against targets for the agency, teams and consultants - `GET /quotas/plan` - Get quota plan - `GET /quotas/periods` - List quota periods - `GET /quotas/periods/{id}` - Get quota period board ### Marketplace Cross-agency job and candidate splits: anonymised listings, reveal-on-accept intros, and the deal record both agencies read. Lovelio takes 0% and never touches the money. Needs an active Lovelio plan. - `GET /marketplace/listings` - Browse marketplace listings - `POST /marketplace/listings` - Share to the marketplace - `GET /marketplace/listings/{id}` - Get marketplace listing - `PATCH /marketplace/listings/{id}` - Manage marketplace listing - `GET /marketplace/intros` - List marketplace intros - `POST /marketplace/intros` - Make a marketplace intro - `GET /marketplace/intros/{id}` - Get marketplace intro - `POST /marketplace/intros/{id}/respond` - Respond to a marketplace intro - `GET /marketplace/deals` - List marketplace deals - `GET /marketplace/deals/{id}` - Get marketplace deal - `PATCH /marketplace/deals/{id}` - Marketplace deal handshake ### Webhooks Event subscriptions with HMAC-SHA256 signing - `GET /webhooks` - List webhooks - `POST /webhooks` - Create webhook - `GET /webhooks/events` - List webhook events - `GET /webhooks/{id}` - Get webhook - `PATCH /webhooks/{id}` - Update webhook - `DELETE /webhooks/{id}` - Delete webhook - `POST /webhooks/{id}/rotate-secret` - Rotate webhook secret - `POST /webhooks/{id}/test` - Test webhook - `GET /webhooks/{id}/deliveries` - List webhook deliveries - `POST /webhooks/{id}/deliveries/{deliveryId}/replay` - Replay webhook delivery ### Batch Execute multiple canonical actions in a single request with per-op idempotency - `POST /batch` - Execute multiple actions in one request ### Activities Audit log and activity tracking - `GET /activities` - List activities - `POST /activities` - Add a note ### Documents Files attached to any record - CVs, contracts, briefs, right-to-work scans - `GET /documents` - List documents on a record - `POST /documents` - Upload a document - `GET /documents/{id}` - Get a document - `DELETE /documents/{id}` - Delete a document - `GET /documents/{id}/download` - Get a download link ### Tasks Async operation polling - `GET /tasks/{id}` - Get task status ### Outreach AI-powered candidate outreach - `POST /outreach/generate` - Generate AI outreach - `GET /outreach` - List outreach records - `POST /outreach` - Create outreach record ### Talent Pools Talent pool membership and candidate rediscovery - `GET /talent-pools` - List talent pools - `POST /talent-pools` - Create a talent pool - `GET /talent-pools/{id}` - Get talent pool - `PATCH /talent-pools/{id}` - Update talent pool - `DELETE /talent-pools/{id}` - Delete talent pool - `GET /talent-pools/{id}/members` - List pool members - `POST /talent-pools/{id}/members` - Add candidate to pool - `DELETE /talent-pools/{id}/members/{memberId}` - Remove pool member - `GET /talent-pools/{id}/requests` - List join requests - `POST /talent-pools/{id}/requests/{requestId}/approve` - Approve join request - `POST /talent-pools/{id}/requests/{requestId}/decline` - Decline join request - `POST /public/talent-pools/{employerSlug}/{poolSlug}/join` - Ask to join a talent pool ### Review Queue Items Lovelio cannot decide on its own and needs a recruiter to handle - `GET /review-queue` - List review queue items - `PATCH /review-queue/{id}` - Update a review queue item ### Forms Form templates, phone screen execution, and reference check delivery - `GET /forms` - List forms - `POST /forms` - Create form - `GET /forms/{id}` - Get form with questions - `PATCH /forms/{id}` - Update form - `DELETE /forms/{id}` - Delete form - `POST /forms/{id}/questions` - Add question to form - `PATCH /forms/{id}/questions/{questionId}` - Update question - `DELETE /forms/{id}/questions/{questionId}` - Delete question - `GET /form-instances` - List form instances - `GET /form-instances/{id}` - Get form instance - `POST /form-instances/screening` - Create screening instance - `POST /form-instances/reference` - Create reference instance - `POST /form-instances/{id}/send` - Send reference form to referee ### Referees Candidate referee management for reference checks - `GET /candidates/{id}/referees` - List referees - `POST /candidates/{id}/referees` - Add referee - `PATCH /candidates/{id}/referees/{refereeId}` - Update referee - `DELETE /candidates/{id}/referees/{refereeId}` - Delete referee ### Integrations Third-party integrations (Slack, Calendar) - `GET /integrations/slack` - Get Slack integration - `PATCH /integrations/slack` - Update Slack settings - `DELETE /integrations/slack` - Disconnect Slack ### Chat Integrations AI chatbot platform connections (Slack, Teams, WhatsApp, Google Chat) and user identity mappings - `GET /integrations/chat` - List chat integrations - `POST /integrations/chat` - Connect chat platform - `PATCH /integrations/chat/{id}` - Update chat integration - `DELETE /integrations/chat/{id}` - Disconnect chat platform - `GET /integrations/chat/{id}/users` - List user mappings - `POST /integrations/chat/{id}/users` - Create user mapping - `DELETE /integrations/chat/{id}/users/{userId}` - Delete user mapping ### Analytics Hiring dashboard, pipeline funnel analysis, and KPI metrics - `GET /analytics/dashboard` - Dashboard statistics - `GET /analytics/pipeline` - Pipeline analysis - `GET /analytics/metrics` - Hiring metrics ### Calendar Shared calendar events for interviews, phone screens, reminders, deadlines, and meetings - `GET /calendar-events` - List calendar events - `POST /calendar-events` - Create calendar event - `GET /calendar-events/{id}` - Get calendar event - `PATCH /calendar-events/{id}` - Update calendar event - `DELETE /calendar-events/{id}` - Delete calendar event ### 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 ### Workflow Rules Per-company configuration for AI thresholds, HM deadlines, and follow-up cadences - `GET /workflow-rules` - Get workflow rules - `PATCH /workflow-rules` - Update workflow rules ### Email Templates Canonical transactional email templates and per-company AI instruction overrides - `GET /email-templates` - List email templates - `PATCH /email-templates` - Update one or more email templates ### Stages Canonical pipeline stage reference (labels, transitions, terminal flags) - `GET /stages` - List canonical pipeline stages ### Distribution Job board catalogue, posting rules, multi-board distribution, and the posting ledger - `GET /distribution/boards` - List job boards - `GET /distribution/rules` - List distribution rules - `POST /distribution/rules` - Create a distribution rule - `PATCH /distribution/rules/{id}` - Update a distribution rule - `DELETE /distribution/rules/{id}` - Delete a distribution rule - `GET /distribution/postings` - List job board postings - `POST /distribution/publish` - Distribute a job to boards ### Clients - `GET /clients` - List clients - `POST /clients` - Create client - `GET /clients/{id}` - Get client - `PATCH /clients/{id}` - Update client - `DELETE /clients/{id}` - Delete client - `GET /clients/{id}/contacts` - List client contacts - `POST /clients/{id}/contacts` - Add client contact - `PATCH /clients/{id}/contacts/{contactId}` - Update client contact - `DELETE /clients/{id}/contacts/{contactId}` - Delete client contact ### Specs - `GET /specs` - List specs - `POST /specs` - Create and send a spec - `GET /specs/{id}` - Get spec ### Search - `POST /search` - Natural language search ### Business Development - `GET /bd/targets` - List BD targets - `GET /bd/targets/{id}` - Get BD target - `GET /bd/leads` - List BD leads - `GET /bd/leads/{id}` - Get BD lead - `GET /bd/patch` - Get BD patch --- ## System Health and status ### GET /health **Health check** Operation ID: `getHealth` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | OK | | ### POST /health **Health check (POST)** Same as GET. POST is accepted so this endpoint can be used as a webhook test target. Operation ID: `postHealth` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | OK | | ## Accounts Account and API key management ### POST /accounts/signup **Create account** Public, unauthenticated signup. Takes a work email; the company name and website are derived from the email domain (free-email domains are refused). Returns 202 with a 7-day trial API key (key_type "trial", admin scopes) that can READ immediately - writes stay blocked with 403 EMAIL_NOT_VERIFIED until the customer clicks the verification email. The trial_api_key is shown exactly once. Async enrichment (branding, logo, timezone) runs behind enrichment_task_id; an account.enriched webhook fires when it completes. IP rate limit: 3 signups/hour, 5/day. Operation ID: `createAccount` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | Work email address. Free/personal domains (gmail etc.) are refused. | | `source` | string | no | Where the signup came from (e.g. "claude-agent"). Defaults to "api". | **Responses** | Code | Description | Body | | --- | --- | --- | | `202` | Account created (or, for an existing unverified account with the same email, re-acknowledged WITHOUT a new key - duplicate signups are a no-op). data: { account_id, status: "pending_verification", trial_api_key (new accounts only, shown once), email_verified: false, message, enrichment_task_id }. | { success, data: object } | | `409` | Another account already claims this email or its domain (error.code CONFLICT). | | | `422` | Missing/invalid email, or a free-email domain. | | | `429` | Rate limit exceeded (3/hour, 5/day per IP). Retry-After header set. | | ### POST /accounts/verify-email **Verify account email** Completes signup by redeeming the token from the verification email. No authentication required - the token is the credential. Tokens are single-use and expire; a used or expired token returns 409 / 422. Operation ID: `verifyAccountEmail` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | yes | Verification token from the email link. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Email verified, account active | { success, data: object } | | `409` | Token already used | | | `422` | Token missing, invalid, or expired | | ### GET /accounts/me **Get current account** Operation ID: `getMyAccount` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Account details | { data: object } | ### GET /accounts/{id} **Get account** Operation ID: `getAccount` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Account details | { success, data: Account } | ### PATCH /accounts/{id} **Update account** Update mutable fields on the account. Partners can also mark onboarding as complete to bypass the in-app wizard. Operation ID: `updateAccount` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `company_name` | string | no | | | `tone_of_voice` | string \| null | no | | | `logo_url` | string \| null | no | | | `website_url` | string \| null | no | | | `location` | string \| null | no | | | `onboarding_completed_at` | boolean \| string (date-time) \| null | no | Pass true to mark onboarding complete (server sets NOW()), false/null to clear it, or an ISO 8601 timestamp to set it exactly. | | `timezone` | string | no | IANA timezone (e.g. "Europe/London"). Setting this marks timezone_source as "manual" so automated enrichment will not overwrite it. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated account | { success, data: Account } | ### GET /accounts/{id}/users **List users** Operation ID: `listUsers` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of users | { success, data: array of User } | ### POST /accounts/{id}/users **Invite a user** Operation ID: `createUser` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Full name. Alternatively pass first_name + last_name. | | `first_name` | string | no | Given name. Preferred over name. | | `last_name` | string | no | Family name. | | `email` | string (email) | yes | Must be on the account's email_domain when one is set, else the invite is rejected. | | `phone` | string | no | | | `role` | string (`admin`, `team_leader`, `recruiter`) | no | | | `team_id` | string | no | Team (tem_ id) the user joins when they accept the invite. | | `role_title` | string | no | | | `linkedin_url` | string | no | | | `slack_user_id` | string | no | | | `whatsapp_number` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | User invited | { success, data: User } | ### GET /accounts/{id}/users/{userId} **Get user** Operation ID: `getUser` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `userId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | User details | { success, data: User } | ### PATCH /accounts/{id}/users/{userId} **Update user** Operation ID: `updateUser` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `userId` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Full name. Alternatively pass first_name + last_name; either way first/last and the display name stay in step. | | `first_name` | string | no | | | `last_name` | string | no | | | `email` | string (email) | no | Must be on the account's email_domain when one is set. | | `phone` | string | no | | | `role` | string (`admin`, `team_leader`, `recruiter`) | no | | | `role_title` | string | no | | | `team_id` | string \| null | no | Assign the member to a team (tem_ id), or null to clear. Requires the user to have accepted their invite. | | `linkedin_url` | string | no | | | `slack_user_id` | string | no | | | `whatsapp_number` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated user | { success, data: User } | ### DELETE /accounts/{id}/users/{userId} **Delete user** Operation ID: `deleteUser` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `userId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `204` | User deleted | | ### GET /accounts/{id}/teams **List teams** Operation ID: `listTeams` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of teams | { success, data: array of Team } | ### POST /accounts/{id}/teams **Create team** Operation ID: `createTeam` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `parent_team_id` | string \| null | no | Parent team this team nests under, or null/omitted for top level. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Team created | { success, data: Team } | ### GET /accounts/{id}/teams/{teamId} **Get team** Operation ID: `getTeam` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `teamId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Team details | { success, data: Team } | ### PATCH /accounts/{id}/teams/{teamId} **Update team** Operation ID: `updateTeam` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `teamId` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `parent_team_id` | string \| null | no | Re-parent the team. Send null to move it back to the top level. Must not create a cycle. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated team | { success, data: Team } | ### DELETE /accounts/{id}/teams/{teamId} **Delete team** Operation ID: `deleteTeam` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `teamId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `204` | Team deleted | | ### GET /accounts/{id}/api-keys **List API keys** Operation ID: `listApiKeys` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of API keys | { success, data: array of ApiKey } | ### POST /accounts/{id}/api-keys **Create API key** Returns the full API key once - store it securely. Operation ID: `createApiKey` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `scopes` | array of string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | API key created with full key value | { success, data: ApiKey, key } | ### DELETE /accounts/{id}/api-keys/{keyId} **Revoke API key** Operation ID: `revokeApiKey` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `keyId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `204` | API key revoked | | ### GET /accounts/{id}/sso **List SSO connections** Returns all SSO connections configured for this account. Operation ID: `listSsoConnections` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of SSO connections | { success, data: array of SsoConnection } | ### POST /accounts/{id}/sso **Create SSO connection** Creates a pending SSO connection for the account. Requires an active subscription. The admin must finish IdP configuration through the WorkOS Admin Portal before the connection becomes active. Operation ID: `createSsoConnection` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `domain` | string | yes | | | `provider` | string (`google`, `microsoft`, `saml`) | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Connection created | { success, data: SsoConnection } | | `402` | Plan upgrade required | | | `409` | Connection already exists for this domain | | | `503` | SSO provider not configured on server | | ### DELETE /accounts/{id}/sso/{connectionId} **Remove SSO connection** Operation ID: `deleteSsoConnection` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `connectionId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `204` | Connection deleted | | ### GET /accounts/{id}/transcription-usage **Get video transcription usage** Returns this calendar month's video-transcription usage and the plan cap. Lovelio covers the underlying vendor cost. Active (paying) accounts get a larger monthly cap than trialing accounts. Operation ID: `getTranscriptionUsage` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Usage snapshot | { success, data: object } | ### POST /accounts/{id}/upgrade **Activate account outside Stripe Checkout** Sets the account to active at the given seat count and promotes its trial API key to a live one. For partner-comped accounts; normal billing goes through Stripe Checkout in the dashboard. Lovelio has one plan with everything included, so there is no tier to choose. Operation ID: `upgradeAccount` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `seats` | integer, min 1, default 1 | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Account upgraded | { success, data: object } | ### GET /accounts/{id}/intake-slo **Get intake pipeline SLO** Returns p50/p95/p99 total pipeline latency for the intake agent loop (CV parse + AI assessment + auto-decision) over the last 30 days, plus the decision-path breakdown and parse success rate. Use this to surface "first response to candidate: 30 seconds" style claims with real numbers. Operation ID: `getIntakeSlo` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Intake SLO reading | { success, data: object } | ### GET /accounts/{id}/intelligence **Get company intelligence profile** Returns the synthesised seven-section Company Intelligence profile used by every AI prompt builder (job ads, outreach, assessments). Includes freshness fields (is_stale, last_synthesised_at, tier) so callers can render a stale banner and decide when to POST /intelligence/refresh. Operation ID: `getCompanyIntelligence` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Company intelligence profile | { success, data: object } | ### POST /accounts/{id}/intelligence/refresh **Refresh company intelligence** Queues a fresh deep-enrich + seven-pass synthesis run. Returns 202 immediately with a task_id and poll_url. Rate-limited by a 10-minute per-account cool-down. Requires an Idempotency-Key header. Operation ID: `refreshCompanyIntelligence` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Header parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `202` | Refresh queued | { success, data: object } | | `409` | Cool-down active (refreshed in the last 10 minutes). | | ## Jobs Job requisition management with AI enrichment ### GET /jobs **List jobs** Operation ID: `listJobs` **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 | | `status` | string (`active`, `on_hold`, `closed`, `filled`) | no | | | `client_id` | string | no | Filter to jobs for one client (cli_ id). | | `team_id` | string | no | | | `title` | string | no | Case-insensitive substring match on the job title. | | `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 list of jobs | { success, data: array of Job, meta } | ### POST /jobs **Create job (async)** Creates a job and triggers AI enrichment (description, criteria, interview questions). Returns 202 with a task ID to poll. Operation ID: `createJob` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | | | `mode` | string (`draft`, `active`) | no | Create as draft or active. Default active. | | `source` | string | no | Provenance label stored on the create task. Defaults to "api". | | `source_context` | string \| null | no | Free-text context stored alongside source. | | `compensation_research` | boolean, default false | no | When true, the create task also runs compensation research for the role. | | `client_id` | string | yes | Required. The client this job is for (cli_ id). Every job belongs to a client - for the agency's own internal hiring, pass the agency self-client id. Omitting it returns 422. | | `client_contact_id` | string | no | Contact at the client (cct_ id). Must belong to client_id. | | `employment_type` | string (`full_time`, `part_time`, `contract`) | no | Basis of the role. Omit to leave unset. Any other value returns 422. | | `compensation` | object | no | Salary band for the role. | | `compensation.base_min` | number \| null | no | | | `compensation.base_max` | number \| null | no | | | `compensation.currency` | string | no | ISO 4217 code. When omitted it is derived, in order, from the country in `location`, the client company's own location, then the agency's default currency. | | `compensation.period` | string (`year`, `day`, `hour`) | no | How base_min/base_max are quoted. Omit and the work type decides: employment_type contract gives hour, anything else year. Day rates are never inferred - send day to get one. | | `success_in_90_days` | string \| null | no | What great looks like 90 days in. Feeds AI enrichment of the job criteria. | | `location` | object | no | Structured location. `type` is one of `on_site`, `hybrid`, `remote`. For remote roles, city/country/country_code are ignored. | | `location.type` | string (`on_site`, `hybrid`, `remote`) | no | | | `location.city` | string \| null | no | | | `location.state` | string \| null | no | | | `location.country` | string \| null | no | | | `location.country_code` | string \| null | no | ISO 3166-1 alpha-2 | | `team_id` | string | no | | | `hiring_manager_id` | string | no | | | `default_interview_format` | string (`video`, `phone`, `in_person`, `technical`), default "video" | no | Default interview format used when auto-booking interviews on stage transition. | | `is_evergreen` | boolean, default false | no | Create as an always-open pipeline job, excluded from performance metrics. | **Responses** | Code | Description | Body | | --- | --- | --- | | `202` | Job creation queued. Returns a Task: `resource_id` is the created job's id (job_), `task_id` is the create_job task - poll `poll_url` for enrichment progress. | { success, data: Task } | ### POST /jobs/from-description **Draft a job from a natural-language description (streaming)** Takes a sentence or two describing a role ("senior backend engineer in London, 120k, lead the payments rewrite") and streams a structured JobDraft back via Server-Sent Events. Pass client_id to write the draft in that client's voice, grounded in the client profile and Client DNA; the client is never named or otherwise identified in the copy (agencies keep their client list confidential). The output is funnelled through the shared anti-AI-tics post-processor so the copy does not read like generic AI output. The response Content-Type is text/event-stream; each event is `data: \n\n` where is one of `{type:"meta",review_token,draft_id}`, `{type:"field_start",field}`, `{type:"token",field,text}`, `{type:"field_end",field,value}`, `{type:"complete",draft,review_token,lint:{offenders}}`, or `{type:"error",message}`. The stream terminates with `data: [DONE]\n\n`. This endpoint stages a draft only - call POST /jobs/from-description/{review_token}/confirm to promote it to a real job (which enqueues the same asynchronous create_job task as POST /jobs). Operation ID: `draftJobFromDescription` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `description` | string | yes | Natural-language description of the role. | | `autopublish` | boolean, default false | no | If true, confirming the draft publishes the job ad to the careers page immediately. Defaults to false: the job ad is still generated, but stays in Draft status until published. | | `source` | string (`dashboard`, `slack`, `mcp`, `api`), default "api" | no | Caller self-identification for attribution. Does not affect behaviour. | | `client_id` | string | no | The client this job is for (cli_ id). The draft is written in the client's voice using the client profile and Client DNA, and the client is stored on the draft so /confirm does not need to pass it again. The client is never named in the generated copy. Every job needs a client by confirm time - supply it here (recommended) or via PATCH before confirming. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | SSE stream of DraftEvent messages. The final `complete` event carries the full draft and a review_token to pass to /confirm. | string | | `422` | Missing or invalid description, or client_id does not match a client in this workspace. | | ### POST /jobs/from-description/{review_token}/confirm **Confirm a staged draft and create the job (async)** Promotes a draft staged by POST /jobs/from-description into a real job. Enqueues the same create_job task runner that POST /jobs uses, so assessment criteria, interview questions, and job ad generation happen asynchronously. Returns 202 Accepted with a task_id to poll. The caller may override the draft (for example after editing fields in a dashboard or Slack modal) by passing `draft` in the body; omit to use the streamed draft verbatim. Every job belongs to a client: supply client_id when staging the draft (POST /jobs/from-description) or via PATCH /jobs/from-description/{review_token} before confirming. Operation ID: `confirmJobDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `review_token` | string | yes | The review_token emitted in the `meta` and `complete` SSE events from the draft endpoint. | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `draft` | object | no | Optional edited draft to persist instead of the one originally streamed. | | `draft.title` | string | no | | | `draft.summary` | string | no | | | `draft.responsibilities` | array of string | no | | | `draft.requirements` | array of string | no | | | `draft.nice_to_haves` | array of string | no | | | `draft.benefits` | array of string | no | Candidate-facing benefits (medical, leave, perks). Drawn only from the recruiter's description or the client's profile - never the agency's own benefits. | | `draft.location` | string \| null | no | Denormalised display string ("Sydney, Australia" or "Remote"). Structured siblings on the draft object are location_type, location_city, location_country, location_country_code. | | `draft.location_type` | string (`In Office`, `Hybrid`, `Remote`) \| null | no | | | `draft.location_city` | string \| null | no | | | `draft.location_country` | string \| null | no | | | `draft.location_country_code` | string \| null | no | ISO 3166-1 alpha-2 | | `draft.compensation` | object \| null | no | | | `draft.compensation.base_min` | number \| null | no | | | `draft.compensation.base_max` | number \| null | no | | | `draft.compensation.currency` | string | no | | | `draft.compensation.period` | string (`Per Year`, `Per Day`, `Per Hour`) | no | How the figures are quoted on the draft. Omitted means the work type decides at confirm. | | `draft.employment_type` | string (`full_time`, `part_time`, `contract`) \| null | no | | | `mode` | string (`draft`, `active`) | no | Create as draft or active. Defaults to the autopublish flag provided at draft time. Both modes generate a job ad; active publishes it to the careers page, draft leaves it in Draft status. | | `team_id` | string | no | Optional team ID to attach (tem_...). | | `hiring_manager_id` | string | no | Optional hiring manager user ID (usr_...). | **Responses** | Code | Description | Body | | --- | --- | --- | | `202` | Job creation queued. task_id returned in data. | { success, data: Task } | | `409` | Draft has gaps - the client is missing (the only mandatory field since 4.1.0; team, location, compensation, and employment type are optional and editable on the created job). Standard error envelope: success false, error `{ code: "draft_has_gaps", message }`, and data `{ code, gaps: string[], review_token }` for machines. Set client_id via PATCH /jobs/from-description/{review_token} (updates.client_id), or pass client_id in this confirm body, and retry. There is no `force` bypass. | | | `422` | Draft missing, expired, or already confirmed. | | ### GET /jobs/from-description/{review_token} **Get the current state of a staged job draft** Poll the streaming job draft while the describe-a-job task is running, or after it completes to inspect the final draft and any remaining gaps. Returns the draft fields produced so far, structured selections (team_id / hiring_manager_id), skipped gaps, a suggested team, the lint offenders list, and the task_status (`queued` | `processing` | `completed` | `failed`). Consumers typically poll until `task_status === "completed"` and `gaps` is empty before calling confirm. Operation ID: `getJobDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `review_token` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Draft state | { success, data: object } | | `404` | Draft not found. | | ### PATCH /jobs/from-description/{review_token} **Update a staged job draft (fill gaps)** Fill one or more gaps on an in-progress draft. Every update triggers an AI summary rewrite so the draft reads coherently as structured fields are added. Pass only the fields you want to change in `updates`. Use `skip` to mark gaps as deliberately blank (the confirm endpoint only refuses while client_id is unfilled - it cannot be skipped). Operation ID: `updateJobDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `review_token` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `updates` | object | no | | | `updates.title` | string | no | | | `updates.client_id` | string | no | Client (cli_ id) the job is for - the one mandatory gap. Must belong to the account. | | `updates.client_name` | string | no | Optional display name override for the client. | | `updates.team_id` | string | no | | | `updates.team_name` | string | no | | | `updates.hiring_manager_id` | string | no | | | `updates.hiring_manager_name` | string | no | | | `updates.location` | object | no | | | `updates.location.display` | string | no | | | `updates.location.type` | string (`on_site`, `hybrid`, `remote`) | no | | | `updates.location.city` | string \| null | no | | | `updates.location.country` | string \| null | no | | | `updates.location.country_code` | string \| null | no | | | `updates.compensation` | object | no | | | `updates.compensation.base_min` | number \| null | no | | | `updates.compensation.base_max` | number \| null | no | | | `updates.compensation.currency` | string | no | | | `updates.compensation.period` | string (`Per Year`, `Per Day`, `Per Hour`) | no | How the figures are quoted. Omit and the work type decides when the draft is confirmed. | | `updates.employment_type` | string (`full_time`, `part_time`, `contract`) | no | | | `skip` | array of string | no | Gap fields to mark as skipped. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated draft + remaining gaps | { success, data: object } | | `422` | Draft missing, expired, or already confirmed. | | ### POST /jobs/from-description/{review_token}/revise **Apply a free-text revision to a staged draft** Apply a natural-language instruction ("make it punchier", "move to Sydney and bump salary to AUD 180k") to a staged draft. Applies the company brand voice + anti-AI-tics guardrails. Resolves team / hiring manager names against the company's lists so structured fields update alongside the prose. Operation ID: `reviseJobDraft` **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 draft | { success, data: object } | | `422` | Invalid instruction or draft in a non-revisable state. | | ### GET /jobs/{id}/ad **Get the latest job ad** Fetch the most recent published job ad for a job. Every job created via the describe-a-job flow gets an auto-generated ad on the careers page; this endpoint reads it. Operation ID: `getJobAd` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Job ad | { success, data: object } | | `404` | No ad exists for this job. | | ### PATCH /jobs/{id}/ad **Update a job ad** Edit a job ad's title, description, or published state. Accepts any subset of the three fields. Operation ID: `updateJobAd` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | | | `description` | string | no | | | `published` | boolean | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated ad | object | | `404` | No ad exists for this job. | | ### POST /jobs/{id}/ad/rewrite **AI-rewrite a job ad** Apply a natural-language instruction to rewrite an existing ad. Applies the company brand voice + anti-AI-tics guardrails. Returns the new ad body; the DB is updated in place. Operation ID: `rewriteJobAd` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `instruction` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Rewritten ad | { success, data: object } | | `404` | No ad exists for this job. | | ### GET /jobs/{id}/social-draft **Get a job's LinkedIn + X post drafts** Fetch the auto-generated LinkedIn and X (Twitter) social post drafts for a job, plus the OG image URL used for unfurl previews. Operation ID: `getSocialDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Social draft | { success, data: object } | | `404` | Job not found. | | ### PATCH /jobs/{id}/social-draft **Update a job's social post text** Edit the LinkedIn and/or X post text. og_image_url is preserved automatically. Operation ID: `updateSocialDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `linkedin` | string | no | | | `twitter` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated social draft | object | | `404` | Job not found. | | ### GET /jobs/{id} **Get job** Operation ID: `getJob` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Job details | { success, data: Job } | ### PATCH /jobs/{id} **Update job** Operation ID: `updateJob` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | | | `status` | string (`active`, `on_hold`, `closed`, `filled`) | no | | | `location` | object | no | Structured location. `type` is one of `on_site`, `hybrid`, `remote`. For remote roles, city/country/country_code are ignored. | | `location.type` | string (`on_site`, `hybrid`, `remote`) | no | | | `location.city` | string \| null | no | | | `location.country` | string \| null | no | | | `location.country_code` | string \| null | no | ISO 3166-1 alpha-2 | | `employment_type` | string (`full_time`, `part_time`, `contract`) | no | | | `compensation` | object | no | | | `compensation.base_min` | number | no | | | `compensation.base_max` | number | no | | | `compensation.currency` | string | no | | | `compensation.period` | string (`year`, `day`, `hour`) | no | How base_min/base_max are quoted. Only ever changed when sent - changing employment_type never re-labels figures already on the job. | | `client_id` | string \| null | no | The client this job is for (cli_ id). Setting or clearing it also clears client_contact_id unless a matching contact is sent in the same request. | | `client_contact_id` | string \| null | no | Contact at the client (cct_ id). Must belong to the job's client. | | `team_id` | string \| null | no | | | `hiring_manager_id` | string \| null | no | | | `default_interview_format` | string (`video`, `phone`, `in_person`, `technical`) | no | Default interview format used when auto-booking interviews on stage transition. | | `is_evergreen` | boolean | no | Mark or unmark the job as an always-open pipeline job (excluded from performance metrics). | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated job | { success, data: Job } | ### DELETE /jobs/{id} **Delete job** Soft deletes a job. The record is hidden from all queries but not permanently removed. Requires jobs:write scope. Operation ID: `deleteJob` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { deleted, id } | | `404` | Job not found | | ### POST /jobs/{id}/close **Close job** Operation ID: `closeJob` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Job closed | { success, data: Job } | ### POST /jobs/{id}/reopen **Reopen job** Reopen a closed or filled job. Sets status to active. Requires jobs:write. Operation ID: `reopenJob` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Job reopened | { success, data: object } | | `409` | Job is already active. | | | `422` | Plan job limit reached. | | ### GET /jobs/{id}/summary **AI job summary** Operation ID: `getJobSummary` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | AI-generated role summary | { success, data: object } | ### GET /jobs/{id}/ranking **Stack-ranked interviewed candidates for a job** Returns every interviewed candidate on a job ordered by aggregate scorecard score (highest first). Aggregate score is the straight arithmetic mean of the candidate's form_submissions scores on this job. Per-criterion scores are normalised to 0-100 (pass/fail -> 100/0, scale 1-5 -> linear). Pure read, no side effects. Operation ID: `getJobRanking` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Ranked candidate list | { success, data: object } | | `404` | Job not found | | ### GET /jobs/{id}/share-bundle **Get share-ready content bundle for a job** Returns ready-to-share content: a pre-composed X post (under 260 chars), a LinkedIn post (300-400 chars), the public careers URL, an OG image URL, and a short email summary. All copy is adjusted to the company brand voice and passes the anti-AI-tics linter (no em dashes, no banned phrases). Response is cached server-side for 60 seconds. Use this to push share content to a customer Slack bot, an employee feed, or any programmatic amplification surface without logging into the dashboard. Operation ID: `getJobShareBundle` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Share bundle | { success, data: object } | | `404` | Job not found | | ## Job Ads Public job postings and career pages ### GET /job-ads **List job ads** Operation ID: `listJobAds` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer, 1-100, default 25 | no | Rows per page. Clamped to the 1-100 range rather than rejected, so asking for 500 returns 100 - read meta.has_more and page with the cursor. Unparseable values fall back to 25. | | `after` | string | no | Cursor for forward pagination | | `job_id` | string | no | Filter to one job (job_ id). | | `status` | string (`publishing`, `live`, `expired`) | no | Filter by ad status. Any other value is a 422, never silently ignored. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated job ads | { success, data: array of object, meta } | | `422` | Invalid status filter. | | ### POST /job-ads **Create job ad** Operation ID: `createJobAd` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `job_id` | string | yes | External ID of the job this ad advertises. | | `channels` | array of string (`careers_page`, `jobs_widget`, `referio`, `linkedin`, `x`, `amplifiers`) | no | Distribution channels. Include "referio" to post to the referral marketplace (requires a referral_fee). | | `content` | object | no | | | `content.headline` | string | no | | | `content.body` | string | no | | | `referral_fee` | number | no | Fee paid for a successful Referio referral. Required when posting to the referio channel; Referio enforces a US$5,000 minimum. | | `referral_fee_currency` | string | no | ISO 4217 currency for referral_fee. Defaults to the ad currency. | | `expires_at` | string (date-time) \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `202` | Job ad queued for generation and publishing. Poll data.poll_url; data.resource_id is the job_ad id. | { success, data: Task } | ### GET /job-ads/{id} **Get job ad** Operation ID: `getJobAd` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Job ad details | { success, data: object } | ### DELETE /job-ads/{id} **Delete job ad** Soft deletes a job ad. The ad is removed from the careers page and hidden from all queries. Requires jobs:write scope. To edit ad copy, use PATCH /jobs/{id}/ad. Operation ID: `deleteJobAd` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { deleted, id } | | `404` | Job ad not found | | ### POST /job-ads/{id}/expire **Expire job ad** Operation ID: `expireJobAd` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Expired | { success, data: object } | ## Candidates Candidate profiles and talent database ### GET /candidates **List candidates** The list includes people who exist only as client contacts (contact_only: true) - a hiring manager is a future candidate. Filter with the contact_only param to narrow either way. Operation ID: `listCandidates` **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 | | `email` | string | no | Exact (case-insensitive) email lookup. | | `tag` | string | no | | | `location_city` | string | no | Case-insensitive substring match on location. | | `do_not_approach` | boolean | no | Filter by the off-limits flag. Omit to list all candidates - the list includes flagged candidates by default. | | `contact_only` | boolean | no | Role filter. true = only people who exist purely as client contacts; false = only the candidate pool. Omit to list everyone. | | `rating` | integer, 1-5 | no | Exact star rating 1-5. | | `min_rating` | integer, 1-5 | no | Minimum star rating 1-5 ("4 and up" = 4). Unrated candidates never match. | | `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 candidates | { success, data: array of Candidate, meta } | ### POST /candidates **Create candidate** Operation ID: `createCandidate` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Full name. Alternatively pass first_name + last_name. | | `first_name` | string | no | | | `last_name` | string | no | | | `email` | string | yes | | | `phone` | string \| null | no | | | `linkedin_url` | string \| null | no | | | `location` | object | no | | | `location.city` | string | no | | | `skills` | array of string | no | | | `resume_url` | string \| null | no | Public CV URL. Queues an async parse (employment history, skills, education). Sending one draws on the account CV allowance: 5,000 per rolling 30 days, shared with bulk import and /candidates/from-cv, refused with 403 QUOTA_EXCEEDED past it. Creating a candidate WITHOUT a resume_url is uncapped, and applications people send you never count. | | `rating` | integer, 1-5 \| null | no | The agency's 1-5 star rating. | | `tags` | array of string | no | Flat labels to put on the candidate. Deduplicated case-insensitively, so "Python" and "python" are one tag. Unknown labels are created. Max 40 characters each (longer is truncated) and max 20 tags per candidate counted after deduplication - more than 20 is a validation error, never a silent trim. | | `preferred_roles` | array of string \| null | no | Roles the candidate wants next. | | `preferred_seniority` | string (`intern`, `junior`, `mid`, `senior`, `staff`, `lead`, `principal`, `head`, `director`, `vp`, `c_suite`) \| null | no | | | `work_types` | array of string (`permanent`, `contract`, `temp`, `part_time`) \| null | no | | | `open_to_relocation` | boolean \| null | no | | | `available_from` | string (date) \| null | no | | | `preferred_locations` | array of string \| null | no | Places the candidate wants to work; gazetteer-resolved asynchronously. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Created | { success, data: Candidate } | | `403` | QUOTA_EXCEEDED - a resume_url was sent and the account has used its 5,000-CV rolling 30-day allowance. | | | `409` | A live candidate with this email already exists in this company (emails are unique case-insensitively). | | ### GET /candidates/{id} **Get candidate** Operation ID: `getCandidate` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Candidate details | { success, data: Candidate } | ### PATCH /candidates/{id} **Update candidate** Operation ID: `updateCandidate` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `first_name` | string | no | | | `last_name` | string | no | | | `email` | string \| null | no | | | `phone` | string \| null | no | | | `linkedin_url` | string \| null | no | | | `location` | object | no | | | `location.city` | string | no | | | `skills` | array of string | no | | | `tags` | array of string | no | Flat labels on the candidate. Replaces the FULL set: an empty array clears every tag, omitting the field leaves them alone. Deduplicated case-insensitively, so "Python" and "python" are one tag. Unknown labels are created. Max 40 characters each (longer is truncated) and max 20 tags per candidate counted after deduplication - more than 20 is a validation error, never a silent trim. | | `current_title` | string \| null | no | Also accepted as current_job_title. | | `current_company` | string \| null | no | Also accepted as current_employer. | | `do_not_approach` | boolean | no | Off-limits flag. Flagged candidates are excluded from search, matching and suggestions. | | `open_to_work` | boolean \| null | no | Open-to-work flag. Null clears it back to never-captured. | | `rating` | integer, 1-5 \| null | no | The agency's 1-5 star rating. Null clears it. | | `salary_expectation` | string \| null | no | Raw salary expectation ("145k + super", "850/day"). The normalized annual figure, amount, period, and currency derive automatically. | | `notice_period` | string \| null | no | Raw notice period ("4 weeks"). notice_period_days derives automatically. | | `work_rights` | string \| null | no | Raw right-to-work statement ("citizen", "482 visa"). work_rights_status derives automatically. | | `preferred_roles` | array of string \| null | no | Roles the candidate wants next. Null clears. | | `preferred_seniority` | string (`intern`, `junior`, `mid`, `senior`, `staff`, `lead`, `principal`, `head`, `director`, `vp`, `c_suite`) \| null | no | Seniority level the candidate wants. Free text like "Head of" normalizes automatically. | | `work_types` | array of string (`permanent`, `contract`, `temp`, `part_time`) \| null | no | Work types the candidate is open to. Variants like "perm" or "full-time" normalize automatically. | | `open_to_relocation` | boolean \| null | no | | | `available_from` | string (date) \| null | no | Stated availability date (YYYY-MM-DD). Null clears. | | `preferred_locations` | array of string \| null | no | Places the candidate wants to work. Replaces the FULL set on every write; each entry is gazetteer-resolved asynchronously for search. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated | { success, data: Candidate } | ### DELETE /candidates/{id} **Delete candidate** Soft deletes a candidate. The record is hidden from all queries but not permanently removed. For permanent GDPR erasure, use the /candidates/{id}/gdpr-delete endpoint instead. Requires candidates:write scope. Operation ID: `deleteCandidate` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { deleted, id } | | `404` | Candidate not found | | ### DELETE /candidates/{id}/gdpr-delete **GDPR delete candidate** Permanently deletes all candidate data including applications, interviews, assessments, and stored CVs. Irreversible. Operation ID: `gdprDeleteCandidate` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { success, deleted, id } | ### POST /candidates/search **Search candidates** Natural language candidate search on the same engine as the in-app search bar: the query compiles into hard requirements and preferences (returned as `interpretation.facets`), hard requirements filter in the database, and a deterministic feature score ranks the rest. `total_count` is the real database count of candidates matching every hard requirement. Pagination walks the ranked pool (`ranked_pool_size`); when the true total exceeds it, a note in `notes` says so. `notes` also carries anything the engine could not do properly (a degraded plan, a truncated scan) - an empty array means everything ran as interpreted. A query naming a company may also return `mentioned_in_cv`: a separate, separately-counted tier of people whose CV names that company while their employment history does not. Those people did not match, and the two counts are never added together. Operation ID: `searchCandidates` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `query` | string | yes | What you are looking for, in plain language. Locations, salaries, employers and time windows belong in the query itself ("sales managers in Sydney on under 150k"). | | `limit` | integer, 1-100, default 25 | no | Results per page. | | `page` | integer, min 1, default 1 | no | 1-based page into the ranked pool. The ranking is deterministic, so page N is always the same slice for the same query. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Ranked matches | { success, data: CandidateSearchResult, meta } | | `422` | query is required, or limit/page out of range. | | | `500` | The search compiler is temporarily unavailable. The API never degrades to a different matcher - retry. | | ### GET /candidates/duplicates **Find duplicate candidates** Operation ID: `findDuplicates` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Duplicate pairs | { success, data: array of object } | ### POST /candidates/merge **Merge candidates** Operation ID: `mergeCandidates` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `keep_id` | string | yes | | | `merge_id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Merged | { success, data: Candidate } | ### POST /candidates/from-cv **Upload a CV and stage a candidate draft** Upload a base64-encoded PDF or DOCX. Lovelio runs a Phase 1 regex parse plus a Phase 2 AI parse (in parallel) and stages the result in candidate_drafts. Returns a `cdr_` review token that every follow-up endpoint under /candidates/from-description/{review_token} consumes. Mandatory fields (name, email) that are missing are reported in `gaps.mandatory`; confirm will refuse until they are filled via PATCH. Each call draws on the account CV allowance: 5,000 per rolling 30 days, shared with bulk import and POST /candidates with a resume_url, refused with 403 QUOTA_EXCEEDED past it. Applications people send you never count towards it. Operation ID: `stageCandidateFromCv` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `file_base64` | string | yes | Raw base64-encoded CV bytes (data-URL prefix optional). | | `mime_type` | string | no | application/pdf or application/vnd.openxmlformats-officedocument.wordprocessingml.document. Defaults to application/pdf. | | `source` | string (`slack`, `mcp`, `dashboard`, `api`) | no | Provenance tag stored on the draft row. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Draft staged | { success, data: object } | | `400` | file_base64 is missing, empty, invalid base64, or larger than 10 MB. Also returned when the file is password-protected or could not be text-extracted. | | | `403` | QUOTA_EXCEEDED - the account has used its 5,000-CV rolling 30-day allowance. | | ### GET /candidates/from-description/{review_token} **Get a staged candidate draft** Read current state of a staged candidate draft. Use this to poll gaps after editing. Operation ID: `getCandidateDraft` **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 /candidates/from-description/{review_token} **Patch fields on a staged candidate draft** Update one or more fields on an in-flight candidate draft (name, email, phone, linkedin_url, location, current_title, current_company). Recomputes gaps. Operation ID: `updateCandidateDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `review_token` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string \| null | no | | | `email` | string \| null | no | | | `phone` | string \| null | no | | | `linkedin_url` | string \| null | no | | | `location` | string \| null | no | | | `current_title` | string \| null | no | | | `current_company` | string \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated draft + remaining gaps | { success, data: object } | | `400` | Draft missing, expired, already confirmed, or cancelled. | | ### POST /candidates/from-description/{review_token}/confirm **Create a candidate from a staged draft** Promote a staged candidate draft by dispatching through the existing create_candidate action handler. Refuses with 409 `{ error: { type: "draft_has_gaps", gaps: { mandatory, warn } } }` when mandatory fields (name, email) are empty. There is no `force` bypass - partners who need to create a candidate without an email use POST /candidates directly. Operation ID: `confirmCandidateDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `review_token` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Candidate created. | { success, data: object } | | `400` | Draft missing, expired, or already confirmed. | | | `409` | Draft has gaps - name or email is empty. Fill them via PATCH /candidates/from-description/{review_token} then retry. | | ## Candidate Imports Bulk CV import - migrate thousands of CVs into the talent bank ### POST /candidate-imports **Start a bulk CV import** Bulk-import CVs (PDF, DOC or DOCX, 10 MB each). Declare how many files you will send, then request upload slots in chunks via POST /candidate-imports/{id}/files, PUT each file body to its upload_url, and confirm with POST /candidate-imports/{id}/finalize. Each file is parsed into a candidate in the background: candidates are matched by email (existing ones are updated, never duplicated), unreadable files are skipped and reported, and the import always completes. Imported candidates land in the talent bank without applications, so imports never trigger AI assessments. Limits: 5,000 files per import, and a 5,000-CV allowance per account per rolling 30 days shared with POST /candidates (when it carries a resume_url) and POST /candidates/from-cv. Both the import and the slot request refuse with 403 QUOTA_EXCEEDED past it. Applications people send you never count towards the allowance. This endpoint is for topping up your database, not for moving off another ATS: a full migration brings across notes, jobs, placements and history that this door does not accept, and Lovelio runs it for you - email support@lovelio.ai. On imports of 200 files or more the deeper parse (skills, work history, education, role and employer) is queued and can take up to a day; names and contact details are written immediately either way. Requires candidates:write. Operation ID: `createCandidateImport` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `file_count` | integer, 1-5000 | yes | How many files this import will send. | | `match_pools` | boolean, default true | no | Match imported candidates against talent pools with AI matching on as they land. Defaults to true; pass false to import without pool matching. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Import created | { success, data: object } | | `403` | QUOTA_EXCEEDED - this account has used its 5,000-file rolling 30-day import allowance | | | `413` | PAYLOAD_TOO_LARGE - file_count is above 5,000 | | ### GET /candidate-imports/{id} **Get import report** Live progress while processing; the full report once complete: per-outcome counts, failed files with human-readable reasons, and role_matches - how the imported candidates compare with your open roles (embedding similarity, 0-100; no assessments are run). Operation ID: `getCandidateImport` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Import report | { success, data: CandidateImport } | | `404` | Import not found | | ### POST /candidate-imports/{id}/files **Request upload slots** Returns one signed upload slot per file (up to 25 per call). PUT the raw file body to each upload_url with any HTTP client - files never travel through this API. Only pdf, doc and docx filenames receive slots. An import cannot accept more files than its declared file_count, and slots stop being issued once the account reaches its 5,000-file rolling 30-day allowance (403 QUOTA_EXCEEDED). Operation ID: `addCandidateImportFiles` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `files` | array of object | yes | | | `files[].filename` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Upload slots | { success, data: object } | | `403` | QUOTA_EXCEEDED - this account has used its 5,000-file rolling 30-day import allowance | | | `409` | Import is no longer accepting files, or file_count would be exceeded | | ### POST /candidate-imports/{id}/finalize **Confirm uploads** Confirm which items were uploaded; each confirmed item is queued for background parsing. Pass complete: true on the last call to close the upload phase - processing then finishes on its own and GET /candidate-imports/{id} becomes the report. Items signed but never confirmed are failed by a sweep after 2 hours, so the import always completes. Operation ID: `finalizeCandidateImport` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `uploaded_item_ids` | array of string | no | | | `complete` | boolean | no | Set true on the final call to close the upload phase. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Confirmed | { success, data: object } | ## Applications Application pipeline with AI assessment ### GET /applications **List applications** Operation ID: `listApplications` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer, 1-100, default 25 | no | Rows per page. Clamped to the 1-100 range rather than rejected, so asking for 500 returns 100 - read meta.has_more and page with the cursor. Unparseable values fall back to 25. | | `after` | string | no | Cursor for forward pagination | | `job_id` | string | no | | | `candidate_id` | string | no | | | `stage` | string | no | | | `source` | string | no | | | `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 applications with assessment scores | { success, data: array of Application, meta } | ### POST /applications **Create application (async)** Attaches an existing candidate to a job and triggers AI assessment. Returns 202. Source attribution: pass either `source` (a slug we map to a category, e.g. linkedin, indeed, job_ad, referral, careers_page) or the structured `source_category` + `source_detail` pair for full control. `source_category` is one of: Careers Page, Job Board, Referral, Talent Pool, Database Search, Social, Agency, Direct, Imported, Other. `source_detail` is the granular sub-source (the exact board, referrer, pool, or search). Omitting source records the API origin (Other / API). Operation ID: `createApplication` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `job_id` | string | yes | External job id (job_...). | | `candidate_id` | string | yes | External candidate id (cnd_...). | | `source` | string \| null | no | Source slug (linkedin, indeed, job_ad, referral, careers_page, ...). Mapped to a category + detail. Ignored when source_category is supplied. | | `source_category` | string (`Careers Page`, `Job Board`, `Referral`, `Talent Pool`, `Database Search`, `Social`, `Agency`, `Direct`, `Imported`, `Other`) \| null | no | Canonical source category. | | `source_detail` | string \| null | no | Granular sub-source (exact board, referrer, pool, or search). | | `cover_note` | string \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `202` | Application queued for assessment | { success, data: Task } | ### GET /applications/{id} **Get application** Operation ID: `getApplication` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Application with assessment | { success, data: Application } | ### PATCH /applications/{id} **Update application** Update non-stage fields (note). The `stage`/`status` fields are no longer accepted - see v1.3.0 breaking change. Returns 410 if stage/status is present. Operation ID: `updateApplication` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `note` | string | no | Free-text note to store against the application. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated | { success, data: Application } | | `410` | Gone - stage/status field rejected. Use POST /applications/{id}/reject or POST /actions instead. | | ### POST /applications/bulk-update **Bulk update applications** Operation ID: `bulkUpdateApplications` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Bulk update results | { success, data: object } | ### POST /phone-screens/from-description **Stage a phone-screen log** Stage a phone-screen outcome log for an application. Phone screen is the simplest draft cluster in Lovelio - three fields (outcome, notes, optional call metadata) with no LLM or streaming. The confirm step atomically writes a timeline note and moves the stage: outcome='advance' / 'hold' move a 'yes' application to 'screen' (the screen happened); outcome='reject' moves any pre-client application (funnel / maybe / yes / screen) to 'no' (no email sent - Send Rejection is a separate action); outcome='no_show' never moves the stage, and an application already with the client is never pulled back. Operation ID: `composePhoneScreenFromDescription` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `application_id` | string | yes | External application ID (app_ prefix) | | `initial_outcome` | string (`advance`, `reject`, `hold`, `no_show`) | no | Pre-fill outcome on the draft. Caller can change it via PATCH before confirm. | | `initial_notes` | string | no | Pre-fill notes on the draft. Required before confirm succeeds. | | `call_at` | string | no | Optional ISO-8601 timestamp of the call. | | `duration_min` | number | no | Optional call duration in minutes. | | `source` | string (`slack`, `mcp`, `dashboard`, `api`) | no | Provenance tag stored on the draft row. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Draft staged. Returns review_token, current draft, and remaining gaps. | { success, data: object } | | `400` | application_id missing or invalid, or body was not JSON. | | | `404` | Application not found in this company. | | ### GET /phone-screens/from-description/{review_token} **Get a staged phone-screen draft** Read current state of a staged phone-screen draft: outcome, notes, call metadata, and remaining gaps. Operation ID: `getPhoneScreenDraft` **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 /phone-screens/from-description/{review_token} **Patch fields on a staged phone-screen draft** Update one or more fields on an in-flight phone-screen draft (outcome, notes, call_at, duration_min). Recomputes gaps. Operation ID: `updatePhoneScreenDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `review_token` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `outcome` | string (`advance`, `reject`, `hold`, `no_show`) \| null | no | | | `notes` | string \| null | no | | | `call_at` | string \| null | no | ISO-8601 timestamp or null to clear | | `duration_min` | number \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated draft + remaining gaps | { success, data: object } | | `422` | Draft missing, expired, or already confirmed. | | ### POST /phone-screens/from-description/{review_token}/confirm **Confirm a phone-screen log** Promote a staged phone-screen draft: atomically log a timeline note AND move the stage per the outcome ('advance' / 'hold' move yes to screen, 'reject' parks pre-client applications at 'no', 'no_show' never moves). Refuses with 409 `{ error: { type: 'draft_has_gaps', gaps: string[] } }` when outcome or notes is empty. Dispatches to the existing add_note + move_stage handlers so every side-effect (timeline entry, automation trigger, undo record) stays consistent. Operation ID: `confirmPhoneScreenDraft` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `review_token` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Phone screen logged. Response contains application_id, outcome, new_stage (nullable for hold / no_show), note_id. | { success, data: object } | | `409` | Draft has gaps - outcome or notes is empty. Fill them via PATCH /phone-screens/from-description/{review_token}, then retry. | | | `422` | Draft missing, expired, or already confirmed. | | ### POST /applications/{id}/stage **Transition application stage** Moves an application to a new pipeline stage. Validates the transition against the canonical pipeline. To force an invalid transition, pass an override_reason. Operation ID: `transitionApplicationStage` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Application external ID (app_ prefix) | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `stage` | string (`funnel`, `maybe`, `yes`, `no`, `screen`, `submitted`, `client_interview`, `references`, `offer`, `placed`, `rejected`, `withdrew`) | yes | | | `source` | string (`slack`, `whatsapp`, `web`, `api`) | yes | | | `override_reason` | string | no | Required to force a transition that is not in the canonical allow-list. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Stage transition completed | { success, data: object } | | `404` | Application not found | | | `422` | Invalid stage or disallowed transition | | ## 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. | | ## Submissions Client-facing shortlists with a tokenised review link and a live feedback loop ### GET /submissions **List submissions** Operation ID: `listSubmissions` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer, 1-100, default 25 | no | Rows per page. Clamped to the 1-100 range rather than rejected, so asking for 500 returns 100 - read meta.has_more and page with the cursor. Unparseable values fall back to 25. | | `after` | string | no | Cursor for forward pagination | | `job_id` | string | no | Filter to one job (job_ id). | | `client_id` | string | no | Filter to one client (cli_ id). | | `status` | string (`pending`, `viewed`, `partially_responded`, `completed`, `expired`, `revoked`) | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated list of submissions, newest first | { success, data: array of Submission, meta } | ### POST /submissions **Create and send a submission** Creates a submission on a job and emails the client contact a tokenised review link. Items without a summary get an AI-drafted one. Candidates still in triage (funnel/maybe/yes/no) move to the submitted stage automatically; candidates already client-side keep their stage. The contact defaults to the job's contact, then the client's primary contact. Anyone already sent to this client for this job is refused with a 400 naming them - pass allow_resubmit: true to send anyway. identity_visibility controls how much of each candidate the client sees, one level for the whole send. Same write path as the in-product flow. Requires submissions:write. Operation ID: `createSubmission` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `job_id` | string | yes | | | `items` | array of object | yes | | | `items[].application_id` | string | yes | | | `items[].summary` | string | no | Client-facing pitch. Omit to have AI draft it. | | `client_contact_id` | string | no | Must belong to the job's client. Defaults to the job's contact, then the primary contact. | | `title` | string | no | | | `greeting` | string | no | Opening line of the email, no trailing comma (e.g. "Hi Brett"). Defaults to "Hi {first name}". | | `email_subject` | string | no | Subject line of the email. Defaults to "{consultant} has shortlisted N candidates for {job title}". Stored on the submission, so a resend repeats it rather than generating a different line. | | `intro_body` | string | no | Everything said above the shortlist, in the consultant's own words, shown in the email and on the review page. Defaults to "{consultant} at {agency} put this shortlist together for you. Review each one and respond...". Stored on the submission, so a resend repeats what the client first read. Blank lines start a new paragraph. | | `message` | string | no | Legacy covering note. It renders under the DEFAULT intro and is ignored when intro_body is set. Use intro_body instead. | | `allow_resubmit` | boolean, default false | no | Send even when a candidate on the list has already gone to this client for this job. Off by default so the same CV is never sent twice by accident. | | `identity_visibility` | string (`full`, `first_name`, `anonymous`), default "full" | no | How much of each candidate the client sees. full: the whole profile. first_name: first name only, surname and LinkedIn removed. anonymous: no name, no LinkedIn, and employers and schools withheld. One level for the whole send. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Created submission. email_sent false means the link works but the email failed - resend from the product. | { success, data: Submission } | ### GET /submissions/{id} **Get submission** Operation ID: `getSubmission` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Submission with per-candidate response state | { success, data: Submission } | | `404` | Submission not found | | ## Placements The record of the win: fee, start date, guarantee lifecycle ### GET /placements **List placements** Operation ID: `listPlacements` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `client_id` | string | no | Filter by client (cli_). | | `candidate_id` | string | no | Filter by candidate (cnd_). | | `job_id` | string | no | Filter by job (job_). | | `status` | string (`pending_start`, `started`, `fell_off`, `completed`) | no | | | `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 placements | { success, data: array of Placement, meta } | ### POST /placements **Log placement** Log a placement: the win. Every placement traces back to a job and a live application. Pass application_id (app_) to log it against the application - it moves to placed automatically. Or pass candidate_id + client_id: the candidate must already be live on exactly one of the client's OPEN jobs - Active or On Hold (pass job_id to disambiguate) - and the placement links to that application; otherwise the request fails validation. A placement on a Closed or Filled job is refused whichever way it is addressed: reopen the job first. Fee percent and guarantee default off the client's fee schedule (banded by salary, minimum fee applied) when omitted; fee.amount is computed at write time. Currency, when omitted, resolves in this order: the client's own fee terms, then the acting user's default_currency, then the agency's standard fee schedule, then the agency's country. A key with no acting user behind it skips the user step. Lovelio never converts between currencies. Requires placements:write. Operation ID: `createPlacement` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `application_id` | string | no | app_ id. Resolves candidate, client, and job. | | `candidate_id` | string | no | cnd_ id. Required with client_id when no application_id. The candidate must be live on one of the client's open jobs. | | `client_id` | string | no | cli_ id. | | `job_id` | string | no | job_ id. Disambiguates when the candidate is on more than one of the client's jobs. | | `salary` | number | no | First-year salary. | | `currency` | string | no | ISO 4217 code. Omit to let it default (see the endpoint description). | | `fee_type` | string (`percent`, `fixed`), default "percent" | no | | | `fee_percent` | number | no | Defaults to the salary's band on the client's fee schedule (minimum fee applied). | | `fee_amount` | number | no | Fixed fee amount (fee_type fixed). | | `placement_type` | string (`permanent`, `contract`) | no | Defaults from the job's employment_type: a contract job logs a contract placement. | | `offer_date` | string (date) | no | When the offer was accepted. Defaults to today. | | `contract_end_date` | string (date) | no | Contract placements: the initial term's end date. | | `charge_rate` | number | no | Contract placements: what the client pays, per rate_period. | | `pay_rate` | number | no | Contract placements: what the contractor gets, per rate_period. | | `rate_period` | string (`hourly`, `daily`, `weekly`) | no | | | `expected_gp` | number | no | Contract placements: expected gross profit over the initial term. Defaults to margin x term off the rates; an explicit value wins. | | `credits` | array of object | no | The credit split - one line per consultant, percents totalling 100. Defaults to the placement owner at 100%. | | `credits[].user_id` | string | no | | | `credits[].percent` | number | no | | | `credits[].role` | string (`candidate_consultant`, `job_owner`, `business_development`, `other`) | no | | | `start_date` | string (date) | no | | | `guarantee_days` | integer | no | Defaults to the client's guarantee_days. | | `notes` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Placement logged | { success, data: Placement } | ### GET /placements/{id} **Get placement** Operation ID: `getPlacement` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Placement details | { success, data: Placement } | ### PATCH /placements/{id} **Update placement** Patch fields (salary, fee, start_date, guarantee_days, notes, contract terms), replace the credit split, or move the lifecycle status (pending_start / started / fell_off / completed). Status changes dispatch a placement.status_changed webhook. fee.amount is recomputed when any fee input moves; expected_gp is recomputed when a contract's rates or term move unless set directly. credits replaces the whole split and must total 100. Operation ID: `updatePlacement` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`pending_start`, `started`, `fell_off`, `completed`) | no | | | `salary` | number | no | | | `currency` | string | no | | | `fee_type` | string (`percent`, `fixed`) | no | | | `fee_percent` | number | no | | | `fee_amount` | number | no | | | `placement_type` | string (`permanent`, `contract`) | no | | | `offer_date` | string (date) | no | | | `contract_end_date` | string (date) | no | | | `charge_rate` | number | no | | | `pay_rate` | number | no | | | `rate_period` | string (`hourly`, `daily`, `weekly`) | no | | | `expected_gp` | number | no | | | `credits` | array of object | no | Replaces the whole split. Percents must total 100. | | `credits[].user_id` | string | no | | | `credits[].percent` | number | no | | | `credits[].role` | string (`candidate_consultant`, `job_owner`, `business_development`, `other`) | no | | | `start_date` | string (date) | no | | | `guarantee_days` | integer | no | | | `notes` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated | { success, data: Placement } | ## Quotas Quota plan, periods and the recognition board: credit-share money against targets for the agency, teams and consultants ### GET /quotas/plan **Get quota plan** The agency's quota plan. Auto-created with defaults on first read, so this never 404s. Read-only: plan defaults, periods and targets are managed in Settings > Quotas. Requires quotas:read. Operation ID: `getQuotaPlan` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | The quota plan | { success, data: QuotaPlan } | ### GET /quotas/periods **List quota periods** Quota periods, newest first. Periods auto-roll on read, so the current one always exists. No cursor: an agency mints at most 12 periods a year, so limit is the whole story. Requires quotas:read. Operation ID: `listQuotaPeriods` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer, 1-100, default 12 | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Quota periods | { success, data: array of QuotaPeriod, meta } | ### GET /quotas/periods/{id} **Get quota period board** One period's quota board: recognised credit-share money, placement counts and targets for the agency, each team and each consultant, plus the period's adjustments ledger. The figures are the quota recognition engine's own answer - recognition basis, credit splits, adjustments and locked-period snapshots all respected - so they always match the Placements dashboard and the Booked card. Money is one line per currency, never converted. The board is pruned to the API key's data scope before it is returned: an all-scope key gets the whole board; a team-scoped key gets its own teams and people, never the agency row or other desks. Pass a qpd_ id, or the literal `current` for the period containing today. Requires quotas:read. Operation ID: `getQuotaPeriodBoard` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | qpd_ id, or `current`. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | The period's quota board | { success, data: QuotaPeriod } | ## Marketplace Cross-agency job and candidate splits: anonymised listings, reveal-on-accept intros, and the deal record both agencies read. Lovelio takes 0% and never touches the money. Needs an active Lovelio plan. ### GET /marketplace/listings **Browse marketplace listings** By default: live anonymised listings from other agencies (the browse surface - pre-scrubbed snapshots only, never source records). Pass mine=true for your own listings at any status. Walk-in and demo tenants cannot browse. Requires marketplace:read. The Marketplace is an opt-in module: every /marketplace route answers 402 PREMIUM_REQUIRED without an active Lovelio plan and 403 MARKETPLACE_DISABLED until an admin turns the module on in Settings > Integrations. Operation ID: `listMarketplaceListings` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `kind` | string (`job`, `candidate`) | no | | | `mine` | boolean, default false | no | true = your own listings at any status. | | `status` | string (`live`, `paused`, `filled`, `expired`, `withdrawn`) | no | Only meaningful with mine=true (browse serves live only). | | `limit` | integer, max 100, default 25 | no | | | `after` | string | no | Cursor from meta.next_cursor | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated listings | { success, data: array of MarketplaceListing, meta } | ### POST /marketplace/listings **Share to the marketplace** Share a job or candidate to the marketplace, anonymised. The ONE write path applies regardless of door: real agencies only, the Split Terms gate (pass accept_terms true on your company's first share), deterministic identity scrub, fail-closed moderation, 30-day expiry, one live listing per source record. Candidate listings require candidate_aware true - the candidate must know they are being represented externally. Requires marketplace:write. Operation ID: `createMarketplaceListing` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `kind` | string (`job`, `candidate`) | yes | | | `job_id` | string | no | job_ id. Required for kind=job. | | `candidate_id` | string | no | cnd_ id. Required for kind=candidate. | | `headline` | string | yes | | | `chips` | array of string | no | Up to 6 fact chips. | | `pitch` | string | yes | | | `location` | string | no | | | `salary_min` | number | no | | | `salary_max` | number | no | | | `currency` | string, default "AUD" | no | | | `fee_percent` | number | no | The declared fee basis. Prefills from the client's fee schedule in-app. | | `owner_share_percent` | integer (`50`, `60`, `70`, `80`), default 50 | no | The split you keep. 50/50 is the industry norm. | | `candidate_aware` | boolean | no | Candidate listings only - must be true. | | `accept_terms` | boolean | no | Accept the current Lovelio Split Terms (required once per company, on first share or first intro). | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Listing live on the marketplace | { success, data: MarketplaceListing } | ### GET /marketplace/listings/{id} **Get marketplace listing** Your own listing at any status; another agency's only while it is live. Operation ID: `getMarketplaceListing` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Listing | { success, data: MarketplaceListing } | | `404` | Not found | | ### PATCH /marketplace/listings/{id} **Manage marketplace listing** Lifecycle moves on your own listing: pause, resume, withdraw, filled, or renew (extends 30 days and re-attests the listing is still real - the anti-ghost-listing control). Requires marketplace:write. Operation ID: `updateMarketplaceListing` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `action` | string (`pause`, `resume`, `withdraw`, `filled`, `renew`) | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated listing | { success, data: MarketplaceListing } | ### GET /marketplace/intros **List marketplace intros** direction=received (default) lists intros waiting on your listings; direction=sent lists intros your agency made. Operation ID: `listMarketplaceIntros` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `direction` | string (`received`, `sent`), default "received" | no | | | `kind` | string (`submission`, `request`) | no | | | `status` | string (`pending`, `accepted`, `declined`, `withdrawn`, `expired`) | no | | | `limit` | integer, max 100, default 25 | no | | | `after` | string | no | Cursor from meta.next_cursor | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated intros | { success, data: array of MarketplaceIntro, meta } | ### POST /marketplace/intros **Make a marketplace intro** Submit one of your candidates to a job listing (kind=submission - requires candidate_id, an anonymous profile, and attested_relationship true: you have interviewed them and they agreed to be represented), or request another agency's candidate listing (kind=request). The profile and note are scrubbed and moderated before they cross tenants; the candidate stays anonymous until the owner accepts. Requires marketplace:write. Operation ID: `createMarketplaceIntro` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `listing_id` | string | yes | mkl_ id. | | `kind` | string (`submission`, `request`) | yes | | | `candidate_id` | string | no | cnd_ id in YOUR tenant (submissions only). | | `headline` | string | no | Anonymised headline (submissions only). | | `chips` | array of string | no | | | `pitch` | string | no | Anonymised pitch (submissions only). | | `note` | string | no | | | `attested_relationship` | boolean | no | Submissions only - must be true. | | `accept_terms` | boolean | no | Accept the current Lovelio Split Terms (required once per company). | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Intro pending with the listing owner | { success, data: MarketplaceIntro } | ### GET /marketplace/intros/{id} **Get marketplace intro** Visible to the two sides only: the agency that sent it and the agency whose listing it is on. Operation ID: `getMarketplaceIntro` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Intro | { success, data: MarketplaceIntro } | | `404` | Not found | | ### POST /marketplace/intros/{id}/respond **Respond to a marketplace intro** Four moves. check (listing owner, read-only): run the prior-knowledge check - did your agency already know this candidate? - and get the verdict WITHOUT revealing anything; run it before accepting. accept (listing owner): the contract moment - the intro locks, the deal forms with the split and terms version snapshotted, identity reveals on the deal, and on submissions the candidate materialises in your pipeline at the shortlist stage. decline (listing owner): with an optional reason. withdraw (the sending agency): pull a pending intro back. Requires marketplace:write. Operation ID: `respondToMarketplaceIntro` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `action` | string (`check`, `accept`, `decline`, `withdraw`) | yes | | | `decline_reason` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | check / decline / withdraw result | { success, data: object } | | `201` | Accepted - the deal is formed | { success, data: object } | | `402` | Active plan required | | | `403` | Not your move (e.g. only the sender withdraws) | | | `404` | Not found - unknown id, or your agency is not a party to this intro | | | `409` | Already responded - the intro is no longer pending | | | `422` | Validation error | | ### GET /marketplace/deals **List marketplace deals** Deals where your agency is a party, rendered from your side (role, your_share_percent, your_share_amount). Operation ID: `listMarketplaceDeals` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`connected`, `in_progress`, `placed`, `dead`) | no | | | `role` | string (`owner`, `partner`) | no | | | `limit` | integer, max 100, default 25 | no | | | `after` | string | no | Cursor from meta.next_cursor | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated deals | { success, data: array of MarketplaceDeal, meta } | ### GET /marketplace/deals/{id} **Get marketplace deal** Visible to the two parties only. Operation ID: `getMarketplaceDeal` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deal | { success, data: MarketplaceDeal } | | `404` | Not found | | ### PATCH /marketplace/deals/{id} **Marketplace deal handshake** Update a deal. add_to_pipeline (with job_id) turns the revealed candidate into a real candidate + application on one of your jobs and links the deal so its status tracks your pipeline - the door for candidate-listing deals, where you pick the job. mark_paid / mark_received is the handshake on a placed deal (idempotent - the first stamp is the record, repeats are no-ops): the placing agency marks the share paid, the receiving agency marks it received. close (with reason) ends an unplaced deal - the reason goes to the other agency and marketplace.deal.closed fires for both sides; a placed deal never closes this way. Facts both sides can see - Lovelio never touches the money itself. Requires marketplace:write. Operation ID: `updateMarketplaceDeal` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `action` | string (`add_to_pipeline`, `mark_paid`, `mark_received`, `close`) | yes | | | `job_id` | string | no | add_to_pipeline only: the receiving agency's job (job_...) to run the candidate at. | | `reason` | string | no | close only, required: why the deal is ending - the other agency reads it. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated deal | { success, data: MarketplaceDeal } | | `402` | Active plan required | | | `404` | Not found | | | `422` | Validation error | | ## Webhooks Event subscriptions with HMAC-SHA256 signing ### GET /webhooks **List webhooks** Operation ID: `listWebhooks` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Webhook subscriptions | { success, data: array of Webhook, meta } | ### POST /webhooks **Create webhook** Creates a webhook subscription. Returns the signing secret once - store it securely. Every event below is really emitted by Lovelio (subscribing to an event nothing fires would be a lie). Valid events: account.enriched, application.received, application.scored, application.stage_changed, application.status_changed, candidate.enriched, email.sent, email.scheduled, email.cancelled, interview.scheduled, interview.cancelled, interview.outcome_recorded, job.created, job_ad.published, placement.created, placement.status_changed, reference.received, submission.created, submission.responded, spec.sent, spec.viewed, spec.responded, spec.converted, marketplace.intro.received, marketplace.deal.formed, marketplace.deal.placed, marketplace.deal.closed, or * for all. Operation ID: `createWebhook` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string (uri) | yes | | | `events` | array of string (`account.enriched`, `application.received`, `application.scored`, `application.stage_changed`, `application.status_changed`, `candidate.enriched`, `email.sent`, `email.scheduled`, `email.cancelled`, `interview.scheduled`, `interview.cancelled`, `interview.outcome_recorded`, `job.created`, `job_ad.published`, `placement.created`, `placement.status_changed`, `reference.received`, `submission.created`, `submission.responded`, `spec.sent`, `spec.viewed`, `spec.responded`, `spec.converted`, `marketplace.intro.received`, `marketplace.deal.formed`, `marketplace.deal.placed`, `marketplace.deal.closed`, `*`), default ["*"] | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Webhook created with signing secret | { success, data: Webhook, signing_secret } | ### GET /webhooks/events **List webhook events** The webhook event catalogue, programmatically. Every event returned is really emitted by Lovelio, so you can validate config or build a subscription UI against this endpoint instead of hardcoding the list. Operation ID: `listWebhookEvents` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Event catalogue | { success, data: object } | ### GET /webhooks/{id} **Get webhook** Operation ID: `getWebhook` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Webhook details | { success, data: Webhook } | ### PATCH /webhooks/{id} **Update webhook** events is validated against the same catalogue as creation (see GET /webhooks/events). Operation ID: `updateWebhook` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string (uri) | no | | | `events` | array of string (`account.enriched`, `application.received`, `application.scored`, `application.stage_changed`, `application.status_changed`, `candidate.enriched`, `email.sent`, `email.scheduled`, `email.cancelled`, `interview.scheduled`, `interview.cancelled`, `interview.outcome_recorded`, `job.created`, `job_ad.published`, `placement.created`, `placement.status_changed`, `reference.received`, `submission.created`, `submission.responded`, `spec.sent`, `spec.viewed`, `spec.responded`, `spec.converted`, `marketplace.intro.received`, `marketplace.deal.formed`, `marketplace.deal.placed`, `marketplace.deal.closed`, `*`) | no | | | `status` | string (`active`, `paused`) | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated webhook | { success, data: Webhook } | ### DELETE /webhooks/{id} **Delete webhook** Operation ID: `deleteWebhook` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `204` | Deleted | | ### POST /webhooks/{id}/rotate-secret **Rotate webhook secret** Operation ID: `rotateWebhookSecret` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | New signing secret | { success, signing_secret } | ### POST /webhooks/{id}/test **Test webhook** Operation ID: `testWebhook` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Test event sent | { success, data: WebhookDelivery } | ### GET /webhooks/{id}/deliveries **List webhook deliveries** Operation ID: `listDeliveries` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Delivery history | { success, data: array of WebhookDelivery, meta } | ### POST /webhooks/{id}/deliveries/{deliveryId}/replay **Replay webhook delivery** Operation ID: `replayDelivery` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `deliveryId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Delivery replayed | { success, data: WebhookDelivery } | ## Batch Execute multiple canonical actions in a single request with per-op idempotency ### POST /batch **Execute multiple actions in one request** Dispatch up to 100 canonical actions (see lib/ai/permissions.ts ActionName for the full list) in a single round-trip. Each operation runs independently: one failing does not prevent later operations. Each has its own idempotency key so an agent can safely retry a single failed op without re-executing the ones that succeeded. The whole batch counts as one call against the per-minute rate limit. Permission for each operation is checked per-op against the API key scopes. Operation ID: `executeBatch` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `operations` | array of object | yes | | | `operations[].id` | string | no | Optional client-supplied correlation ID returned on the matching response op. A server ID is minted if omitted. | | `operations[].op` | string | yes | Canonical action name (e.g. move_stage, send_email, add_note). Full list in the ActionName union. | | `operations[].payload` | object | yes | Action-specific payload. See the action description for shape. Use the same typed external ids (job_, cnd_, app_, cli_, sub_, pla_) the rest of the API uses - the batch endpoint decodes them to internal ids before dispatch. | | `operations[].idempotency_key` | string | yes | Per-operation idempotency key. Replaying the same (company, action, key) within 24h returns the cached result without re-executing. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | HTTP 200 is returned when the batch itself succeeded. Per-operation status is inside `data.operations` - check each op status before treating the batch as a success. | { success, data: object, meta } | | `400` | Batch exceeds 100 operations (error code: BATCH_TOO_LARGE). | | | `422` | Malformed batch body (missing operations, invalid JSON). | | | `429` | Rate limit exceeded. The batch is rejected without partially consuming rate limit. | | ## Activities Audit log and activity tracking ### GET /activities **List activities** Operation ID: `listActivities` **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. | | `resource_type` | string | no | Filter by resource type (job, candidate, application, etc.) | | `resource_id` | string | no | Filter by resource ID | | `activity_type` | string (`created`, `updated`, `note_added`, `deleted`, `stage_changed`, `applied`, `assessment_completed`, `automation_ran`, `email_sent`, `resume_parsed`, `interview_scheduled`, `interview_cancelled`, `placement_created`, `placement_status_changed`, `references_requested`, `references_received`, `call_logged`) | no | Filter by activity type | | `after` | string (date-time) | no | Cursor for pagination - returns activities before this ISO timestamp | | `from` | string (date-time) | no | Start date filter (ISO timestamp) | | `to` | string (date-time) | no | End date filter (ISO timestamp) | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated activity log | { success, data: array of Activity, meta } | ### POST /activities **Add a note** Add a note to any resource (job, candidate, application, interview, client, placement). Operation ID: `createNote` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `resource_type` | string (`job`, `job_ad`, `candidate`, `application`, `interview`, `client`, `placement`) | yes | | | `resource_id` | string | yes | External ID of the resource | | `note` | string | yes | Note text | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Note created | | ## Documents Files attached to any record - CVs, contracts, briefs, right-to-work scans ### GET /documents **List documents on a record** Every document filed against one record, newest first. Imported documents sort by the date they had in the previous ATS. Operation ID: `listDocuments` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `record_type` | string (`candidate`, `client`, `client_contact`, `job`, `job_ad`, `application`, `interview`, `placement`, `submission`, `spec`, `bd_target`, `talent_pool`, `team`, `hiring_manager`) | yes | The kind of record to list documents for. | | `record_id` | string | yes | External ID of the record (cnd_..., cli_..., job_...). | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Documents on the record | { success, data: array of Document, meta } | | `404` | Record not found | | ### POST /documents **Upload a document** Attach a file to any record. Send multipart/form-data - not JSON - so large files do not have to be base64 encoded. Accepts PDF, Word, Excel, PowerPoint, text, RTF, HTML and images, up to 25MB. Operation ID: `createDocument` **Request body** (`multipart/form-data`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `file` | string (binary) | yes | | | `record_type` | string (`candidate`, `client`, `client_contact`, `job`, `job_ad`, `application`, `interview`, `placement`, `submission`, `spec`, `bd_target`, `talent_pool`, `team`, `hiring_manager`) | yes | | | `record_id` | string | yes | External ID of the record. | | `is_cv` | string (`true`, `false`) | no | Candidates only: make this the candidate's current CV. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Document stored | { success, data: Document } | | `400` | Unsupported file type, empty file, or over 25MB | | | `404` | Record not found | | ### GET /documents/{id} **Get a document** The document record. Use /documents/{id}/download for the file itself. Operation ID: `getDocument` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Document | { success, data: Document } | | `404` | Document not found | | ### DELETE /documents/{id} **Delete a document** Deletes the record AND the stored file. There is no soft delete and no undo. Deleting a candidate's current CV also clears their CV pointer. Operation ID: `deleteDocument` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { success, data: object } | | `404` | Document not found | | ### GET /documents/{id}/download **Get a download link** Returns a short-lived signed URL for the file rather than the bytes, so the file streams straight from storage. The link expires after 5 minutes. Operation ID: `downloadDocument` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Signed download link | { success, data: object } | | `404` | Document not found | | ## Tasks Async operation polling ### GET /tasks/{id} **Get task status** Poll async operation status. Use after receiving a 202 response. Operation ID: `getTask` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Task status | { success, data: Task } | ## Outreach AI-powered candidate outreach ### POST /outreach/generate **Generate AI outreach** Generate three personalised outreach drafts for a candidate, in the company brand voice. Pass job_id to write about a specific role. Drafting only - Lovelio never sends them. Operation ID: `generateOutreach` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `candidate_id` | string | yes | cnd_ id. | | `platform` | string (`email`, `linkedin`, `whatsapp`) | yes | | | `job_id` | string | no | Optional job_ id. Anchors the drafts to that role. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Outreach variants | { success, data: object } | ### GET /outreach **List outreach records** The outreach you have recorded against candidates, newest first. Operation ID: `listOutreach` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `candidate_id` | string | no | Only this candidate (cnd_ id). | | `platform` | string (`email`, `linkedin`, `whatsapp`) | no | | | `status` | string (`draft`, `sent`, `replied`, `no_response`) | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of outreach records | { success, data: array of Outreach, meta } | ### POST /outreach **Create outreach record** Record outreach you sent yourself. Lovelio stores it against the candidate; it does not send anything. Operation ID: `createOutreach` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `candidate_id` | string | yes | cnd_ id. | | `platform` | string (`email`, `linkedin`, `whatsapp`) | yes | | | `body` | string | yes | The message you sent. | | `subject` | string \| null | no | Email only. | | `job_id` | string | no | Optional job_ id the outreach was about. | | `status` | string (`draft`, `sent`, `replied`, `no_response`), default "sent" | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Outreach record created | { success, data: Outreach } | ## Talent Pools Talent pool membership and candidate rediscovery ### GET /talent-pools **List talent pools** Operation ID: `listTalentPools` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of talent pools | { success, data: array of TalentPool, meta } | ### POST /talent-pools **Create a talent pool** Operation ID: `createTalentPool` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `slug` | string | no | | | `eligibility` | string | no | | | `auto_add_enabled` | boolean | no | | | `min_job_fit` | integer \| null | no | | | `min_hm_fit` | integer \| null | no | | | `description` | string | no | | | `who_we_hire` | string | no | | | `video_url` | string | no | | | `is_published` | boolean | no | | | `keep_warm_enabled` | boolean | no | | | `cover_color` | string (`cyan`, `green`, `lime`, `amber`, `orange`) | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Talent pool created | { success, data: TalentPool } | ### GET /talent-pools/{id} **Get talent pool** Operation ID: `getTalentPool` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Talent pool | { success, data: TalentPool } | ### PATCH /talent-pools/{id} **Update talent pool** Update any pool field including auto-add rules and is_published. Operation ID: `updateTalentPool` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `eligibility` | string \| null | no | | | `auto_add_enabled` | boolean | no | | | `min_job_fit` | integer \| null | no | | | `min_hm_fit` | integer \| null | no | | | `description` | string \| null | no | | | `who_we_hire` | string \| null | no | | | `video_url` | string \| null | no | | | `is_published` | boolean | no | | | `keep_warm_enabled` | boolean | no | | | `cover_color` | string (`cyan`, `green`, `lime`, `amber`, `orange`, null) \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated talent pool | { success, data: TalentPool } | ### DELETE /talent-pools/{id} **Delete talent pool** Permanently delete the pool. Its members, join requests, shares and scan verdicts go with it; the candidates themselves are never touched. Operation ID: `deleteTalentPool` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { success, data: object } | ### GET /talent-pools/{id}/members **List pool members** Operation ID: `listTalentPoolMembers` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | no | Search within the pool: case-insensitive match on member name, email, job title, or employer. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of members with candidate details | { success, data: array of object, meta } | ### POST /talent-pools/{id}/members **Add candidate to pool** Idempotent: re-adding an existing member returns 200 with already_member=true. Operation ID: `addTalentPoolMember` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `candidate_id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Already a member | | | `201` | Added to the pool | | ### DELETE /talent-pools/{id}/members/{memberId} **Remove pool member** Operation ID: `removeTalentPoolMember` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `memberId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Removed | | ### GET /talent-pools/{id}/requests **List join requests** Operation ID: `listTalentPoolJoinRequests` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`pending`, `approved`, `declined`) | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of join requests | { success, data: array of object, meta } | ### POST /talent-pools/{id}/requests/{requestId}/approve **Approve join request** Creates or matches the candidate by email, kicks off the resume parse when a CV was attached, and adds them to the pool. Operation ID: `approveTalentPoolJoinRequest` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `requestId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Approved | | ### POST /talent-pools/{id}/requests/{requestId}/decline **Decline join request** Silent - the applicant is not emailed. Operation ID: `declineTalentPoolJoinRequest` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `requestId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Declined | | ### POST /public/talent-pools/{employerSlug}/{poolSlug}/join **Ask to join a talent pool** No authentication required. Multipart form: full_name, email, linkedin_url (optional), cv (optional file, 10MB max). Stores a PENDING join request only - no candidate record is created until a recruiter approves. Operation ID: `joinTalentPool` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `employerSlug` | string | yes | | | `poolSlug` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Request received | { ok } | ## Review Queue Items Lovelio cannot decide on its own and needs a recruiter to handle ### GET /review-queue **List review queue items** Returns items in the human review queue. These are applications Lovelio cannot decide on its own (AI score in the grey zone, reference red flag, chase escalation, etc.). Defaults to unresolved, not-snoozed items ordered by most recent. Operation ID: `listReviewQueue` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `resolved` | string (`true`, `false`) | no | Filter by resolved state. Defaults to false. | | `review_type` | string (`score_review`, `reference_red_flag`, `chase_escalation`, `hm_no_response`, `withdrawal`, `strong_match`, `candidate_stalled`, `scorecard_no_decision`, `candidate_replied`, `interview_invite_no_response`, `interview_booked`, `reference_returned`, `referee_details_returned`, `guarantee_ending`, `onboarding_completed`, `submission_no_response`, `placement_starting`, `rejection_due`, `cross_match`, `spec_match`, `client_interview_requested`, `client_passed`, `offer_no_placement`, `interview_confirmed`, `interview_rescheduled`, `interview_cancelled`, `interview_outcome_due`, `interview_no_show`) | no | | | `priority` | string (`urgent`, `high`, `normal`, `low`) | no | | | `include_snoozed` | boolean, default false | no | If true, include items whose snoozed_until is in the future. Defaults to false. | | `limit` | integer, max 100, default 25 | no | | | `cursor` | string | no | created_at cursor for pagination | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | List of review queue items | { success, data: array of ReviewQueueItem, meta } | ### PATCH /review-queue/{id} **Update a review queue item** Mark a review queue item as resolved, change its priority, or snooze it until a future time. Pass snoozed_until: null to clear an existing snooze. Operation ID: `updateReviewQueueItem` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Review queue external ID (rev_ prefix) | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolved` | boolean | no | | | `priority` | string (`urgent`, `high`, `normal`, `low`) | no | | | `snoozed_until` | string (date-time) \| null | no | ISO-8601 timestamp to hide the item until, or null to clear the snooze. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated | { success, data: ReviewQueueItem } | | `404` | Not found | | ## Forms Form templates, phone screen execution, and reference check delivery ### GET /forms **List forms** Operation ID: `listForms` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | List of form templates | { success, data: array of Form, meta } | ### POST /forms **Create form** Operation ID: `createForm` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `category` | string (`screening`, `assessment`, `interview`, `decision`, `offer`, `references`, `onboarding`, `compliance`) | yes | | | `description` | string | no | | | `ai_augmentation_enabled` | boolean | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Form created | { success, data: Form } | ### GET /forms/{id} **Get form with questions** Operation ID: `getForm` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Form with questions | { success, data: object } | ### PATCH /forms/{id} **Update form** Operation ID: `updateForm` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated form | { success, data: Form } | ### DELETE /forms/{id} **Delete form** Operation ID: `deleteForm` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { success, deleted, id } | ### POST /forms/{id}/questions **Add question to form** Operation ID: `addFormQuestion` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `question_text` | string | yes | | | `question_type` | string (`text`, `rating`, `yes_no`, `multiple_choice`) | no | | | `options` | array of string | no | | | `scoring_weight` | number | no | | | `flag_mode` | string (`red`, `green`, `neutral`) | no | | | `required` | boolean | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Question added | { success, data: FormQuestion } | ### PATCH /forms/{id}/questions/{questionId} **Update question** Operation ID: `updateFormQuestion` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `questionId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated | { success, data: FormQuestion } | ### DELETE /forms/{id}/questions/{questionId} **Delete question** Operation ID: `deleteFormQuestion` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `questionId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { success, deleted, id } | ### GET /form-instances **List form instances** Returns compact summaries of form instances for discovery and pagination. Use GET /v1/form-instances/{id} for full question and response data. Operation ID: `listFormInstances` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `instance_type` | string (`screening`, `reference`) | no | | | `status` | string (`draft`, `sent`, `opened`, `started`, `completed`) | no | | | `application_id` | string | no | | | `referee_id` | string | no | | | `form_id` | string | no | | | `limit` | integer, max 100, default 25 | no | | | `cursor` | string | no | created_at cursor for pagination | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | List of form instances | { success, data: array of FormInstance, meta } | ### GET /form-instances/{id} **Get form instance** Returns a form instance with all questions and responses. Operation ID: `getFormInstance` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Form instance with questions and responses | { success, data: FormInstance } | ### POST /form-instances/screening **Create screening instance** Creates a phone screen form instance for an application. Copies questions from the screening form template. Operation ID: `createScreeningInstance` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `application_id` | string | yes | | | `form_id` | string | no | Optional - defaults to the account default screening form | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Screening instance created | { success, data: FormInstance } | ### POST /form-instances/reference **Create reference instance** Creates a reference check form instance for a referee. If the form has AI augmentation enabled, generates additional targeted questions. Operation ID: `createReferenceInstance` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `application_id` | string | yes | | | `referee_id` | string | yes | | | `form_id` | string | no | Optional - defaults to the account default assessment form | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Reference instance created | { success, data: FormInstance } | ### POST /form-instances/{id}/send **Send reference form to referee** Generates a magic link and sends the reference form to the referee via email. Operation ID: `sendFormInstance` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Form sent | { success, data: FormInstance } | ## Referees Candidate referee management for reference checks ### GET /candidates/{id}/referees **List referees** Operation ID: `listReferees` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | List of referees for this candidate | { success, data: array of Referee } | ### POST /candidates/{id}/referees **Add referee** Operation ID: `createReferee` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `email` | string (email) | yes | | | `phone` | string | no | | | `job_title` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Referee added | { success, data: Referee } | ### PATCH /candidates/{id}/referees/{refereeId} **Update referee** Operation ID: `updateReferee` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `refereeId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated | { success, data: Referee } | ### DELETE /candidates/{id}/referees/{refereeId} **Delete referee** Operation ID: `deleteReferee` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `refereeId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { success, deleted, id } | ## Integrations Third-party integrations (Slack, Calendar) ### GET /integrations/slack **Get Slack integration** Operation ID: `getSlackIntegration` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Slack connection status | { success, data: object } | ### PATCH /integrations/slack **Update Slack settings** Operation ID: `updateSlackIntegration` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated | { success, data: object } | ### DELETE /integrations/slack **Disconnect Slack** Operation ID: `deleteSlackIntegration` **Responses** | Code | Description | Body | | --- | --- | --- | | `204` | Disconnected | | ## Chat Integrations AI chatbot platform connections (Slack, Teams, WhatsApp, Google Chat) and user identity mappings ### GET /integrations/chat **List chat integrations** List all connected chat platforms (Slack, Teams, WhatsApp, Google Chat) for the company. Operation ID: `listChatIntegrations` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of connected platforms with chatbot status | { success, data: array of object } | ### POST /integrations/chat **Connect chat platform** Programmatically connect a chat platform for the AI chatbot. Requires platform credentials. Operation ID: `connectChatPlatform` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `platform` | string (`slack`, `teams`, `whatsapp`, `google_chat`, `discord`, `telegram`, `github`, `linear`) | yes | | | `workspace_id` | string | yes | Platform workspace/team ID | | `workspace_name` | string | yes | Human-readable workspace name | | `credentials` | object | no | Platform-specific credentials (bot_token for Slack, etc.) | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Integration created | { success, data: object } | ### PATCH /integrations/chat/{id} **Update chat integration** Enable/disable the chatbot or update settings for a connected platform. Operation ID: `updateChatIntegration` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Integration ID (slk_ or cin_ prefix) | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `chatbot_enabled` | boolean | no | | | `settings` | object | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated integration | { success, data: object } | ### DELETE /integrations/chat/{id} **Disconnect chat platform** Operation ID: `disconnectChatPlatform` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Disconnected | { success } | ### GET /integrations/chat/{id}/users **List user mappings** List all platform user to Lovelio user mappings for an integration. Users are auto-mapped by email on first interaction, but can also be managed via API. Operation ID: `listChatUserMappings` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of user mappings | { success, data: array of object } | ### POST /integrations/chat/{id}/users **Create user mapping** Manually map a platform user to a Lovelio user. Useful for bulk provisioning or when auto-mapping by email is not possible. Operation ID: `createChatUserMapping` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `platform_user_id` | string | yes | Platform-specific user ID (e.g. Slack user ID) | | `platform_workspace_id` | string | yes | Platform workspace ID | | `user_id` | string | yes | Lovelio user ID (usr_ prefix) | | `display_name` | string | no | | | `role` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Mapping created | { success, data: object } | ### DELETE /integrations/chat/{id}/users/{userId} **Delete user mapping** Operation ID: `deleteChatUserMapping` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `userId` | string | yes | User mapping ID (cum_ prefix) | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Mapping deleted | { success } | ## Analytics Hiring dashboard, pipeline funnel analysis, and KPI metrics ### GET /analytics/dashboard **Dashboard statistics** Company-wide dashboard - active jobs, total candidates, pipeline by stage, upcoming interviews, recent hires. `pipeline` is keyed by the canonical agency stages (funnel, maybe, yes, no, screen, submitted, client_interview, references, offer, placed, rejected, withdrew) and every count is exact. Operation ID: `getDashboard` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Dashboard data | { success, data: object } | ### GET /analytics/pipeline **Pipeline analysis** Hiring funnel analysis with conversion rates between stages and biggest dropoff identification. Operation ID: `getPipelineAnalysis` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer, 1-365, default 30 | no | Lookback period in days (clamped to 365) | | `job_id` | string | no | Filter to a specific job | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Funnel analysis with conversion rates | { success, data: object } | ### GET /analytics/metrics **Hiring metrics** Key hiring KPIs - applications received, hires made, offer acceptance rate, interview-to-hire ratio, source breakdown. Operation ID: `getHiringMetrics` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer, 1-365, default 30 | no | Lookback period in days (clamped to 365) | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Hiring KPIs | { success, data: object } | ## Calendar Shared calendar events for interviews, phone screens, reminders, deadlines, and meetings ### GET /calendar-events **List calendar events** Returns calendar events for the company (shared interviews, phone screens, reminders, deadlines, meetings). Filter by time window with start and end, by event_type, or by linked record IDs. Calendar events are automatically created when an interview is scheduled via POST /v1/interviews. Operation ID: `listCalendarEvents` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `start` | string (date-time) | no | Only return events starting at or after this ISO-8601 timestamp. | | `end` | string (date-time) | no | Only return events starting strictly before this ISO-8601 timestamp. | | `event_type` | string (`interview`, `phone_screen`, `reminder`, `deadline`, `meeting`) | no | | | `application_id` | string | no | | | `candidate_id` | string | no | | | `job_id` | string | no | | | `limit` | integer, max 100, default 25 | no | | | `cursor` | string | no | starts_at cursor for forward pagination. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | List of calendar events | { success, data: array of CalendarEvent, meta } | ### POST /calendar-events **Create calendar event** Create a calendar event. For interview events you normally use POST /v1/interviews, which creates both an interview record and a matching calendar event; use this endpoint for standalone entries like reminders, deadlines, and meetings. Operation ID: `createCalendarEvent` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | | | `event_type` | string (`interview`, `phone_screen`, `reminder`, `deadline`, `meeting`) | yes | | | `starts_at` | string (date-time) | yes | | | `ends_at` | string (date-time) \| null | no | | | `all_day` | boolean | no | | | `notes` | string | no | | | `application_id` | string | no | | | `job_id` | string | no | | | `candidate_id` | string | no | | | `assigned_to` | string | no | User ID the event is assigned to. | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Calendar event created | { success, data: CalendarEvent } | | `422` | Validation error | | ### GET /calendar-events/{id} **Get calendar event** Operation ID: `getCalendarEvent` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Calendar event external ID (cal_ prefix) | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Calendar event | { success, data: CalendarEvent } | | `404` | Not found | | ### PATCH /calendar-events/{id} **Update calendar event** Operation ID: `updateCalendarEvent` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | | | `event_type` | string (`interview`, `phone_screen`, `reminder`, `deadline`, `meeting`) | no | | | `starts_at` | string (date-time) | no | | | `ends_at` | string (date-time) \| null | no | | | `all_day` | boolean | no | | | `notes` | string \| null | no | | | `assigned_to` | string \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated calendar event | { success, data: CalendarEvent } | | `404` | Not found | | ### DELETE /calendar-events/{id} **Delete calendar event** Operation ID: `deleteCalendarEvent` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | | | `404` | Not found | | ## 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: \n\n` where 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 | | ## Workflow Rules Per-company configuration for AI thresholds, HM deadlines, and follow-up cadences ### GET /workflow-rules **Get workflow rules** Returns the current workflow rules for the company. A default row is auto-created on first read. Operation ID: `getWorkflowRules` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Current workflow rules | { success, data: WorkflowRules } | ### PATCH /workflow-rules **Update workflow rules** Update any subset of the workflow rules fields. Each reject threshold must stay strictly below its matching yes threshold. Operation ID: `updateWorkflowRules` **Request body** (`application/json`) Schema: [`WorkflowRules`](#workflowrules) **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated workflow rules | { success, data: WorkflowRules } | | `422` | Validation error | | ## Email Templates Canonical transactional email templates and per-company AI instruction overrides ### GET /email-templates **List email templates** Returns the merged view of every canonical Lovelio email template and any per-company overrides. Filter to a single template with template_key. Operation ID: `listEmailTemplates` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `template_key` | string | no | Optional: return only the specified template. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | List of email templates | { success, data: array of EmailTemplate } | ### PATCH /email-templates **Update one or more email templates** Upsert per-company overrides. Passing ai_instructions: null clears an override and falls back to the built-in default. Up to 50 templates per call. Operation ID: `updateEmailTemplates` **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `updates` | array of object | yes | | | `updates[].template_key` | string | yes | | | `updates[].ai_instructions` | string \| null | no | | | `updates[].enabled` | boolean | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated templates | { success, data: array of EmailTemplate } | | `422` | Validation error | | ## Stages Canonical pipeline stage reference (labels, transitions, terminal flags) ### GET /stages **List canonical pipeline stages** Returns Lovelio's twelve canonical agency stages (funnel, maybe, yes, no, screen, submitted, client_interview, references, offer, placed, rejected, withdrew) with label, description, whether the stage is terminal, and which stage new applications start in. There is no transition graph - any stage can move to any other stage via POST /applications/{id}/stage. Use this instead of hardcoding the stage list in client integrations. Operation ID: `listStages` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | List of stages | { success, data: array of object } | ## Distribution Job board catalogue, posting rules, multi-board distribution, and the posting ledger ### GET /distribution/boards **List job boards** The catalogue of integrated job boards available to connect, with your monthly posting allowance per board (limit, used, remaining for the current month). Operation ID: `listDistributionBoards` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of boards | { success, data: array of DistributionBoard } | ### GET /distribution/rules **List distribution rules** Operation ID: `listDistributionRules` **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. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of rules | { success, data: array of DistributionRule } | ### POST /distribution/rules **Create a distribution rule** Route matching jobs to boards. Free/mixed boards auto-publish; paid boards await confirmation. Operation ID: `createDistributionRule` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `target_board_slugs` | array of string | yes | | | `filter` | object | no | See the DistributionRule schema. country is an ISO 3166 alpha-2 code (a country name is normalised to one on write). | | `filter.country` | string | no | | | `filter.category` | string | no | | | `filter.job_type` | string (`Permanent`, `Contract`, `Temporary`) | no | | | `filter.team_id` | string | no | | | `active` | boolean | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Created rule | { success, data: DistributionRule } | ### PATCH /distribution/rules/{id} **Update a distribution rule** Operation ID: `updateDistributionRule` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `patch` | object | no | | | `patch.name` | string | no | | | `patch.target_board_slugs` | array of string | no | | | `patch.filter` | object | no | | | `patch.active` | boolean | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated rule | { success, data: DistributionRule } | ### DELETE /distribution/rules/{id} **Delete a distribution rule** Operation ID: `deleteDistributionRule` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `204` | Deleted | | ### GET /distribution/postings **List job board postings** The per-board posting ledger - where jobs have been posted, with status and errors. Each ad of a job has its own postings, so filter by job_ad_id to scope to one ad. Operation ID: `listDistributionPostings` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `job_id` | string | no | Filter to one job | | `job_ad_id` | string | no | Filter to one job ad | | `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. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Array of postings | { success, data: array of JobBoardPosting } | ### POST /distribution/publish **Distribute a job to boards** With board_slugs, publishes asynchronously to those boards (202 + task). Without, evaluates the company distribution rules and returns the resolved targets. Operation ID: `distributeJob` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `job_id` | string | yes | | | `board_slugs` | array of string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Rule-resolved targets | { success, data: object } | | `202` | Async task accepted | | ## Clients ### GET /clients **List clients** Operation ID: `listClients` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer, 1-100, default 25 | no | Rows per page. Clamped to the 1-100 range rather than rejected, so asking for 500 returns 100 - read meta.has_more and page with the cursor. Unparseable values fall back to 25. | | `after` | string | no | Cursor for forward pagination | | `search` | string | no | Case-insensitive substring match on the client name. | | `domain` | string | no | Exact match on the normalised bare domain. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated list of clients | { success, data: array of Client, meta } | ### POST /clients **Create client** Creates a client. Provide a website and Lovelio fills in the rest asynchronously: logo, industry, locations, and a first-pass Client DNA built from the client's public footprint. Same write path as the in-product flow. Requires clients:write. Operation ID: `createClient` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Required if website is omitted. | | `website` | string | no | Providing this kicks off enrichment + the Client DNA first pass. | | `industry` | string | no | | | `notes` | string | no | | | `allow_duplicate` | boolean | no | By default a create matching an existing client by name or domain is refused with a message naming the existing record's owner. Set true to create a second record deliberately. | | `terms` | object | no | Initial agreed terms (importing your book) - becomes the client's fee schedule v1. Later changes go through the set_client_fee_terms action. | | `terms.standard_fee_percent` | number | no | A flat percent - becomes a single fee band. | | `terms.payment_terms_days` | integer | no | | | `terms.guarantee_days` | integer | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Created client (enrichment_status "pending" when a website was given) | { success, data: Client } | ### GET /clients/{id} **Get client** Operation ID: `getClient` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Client details | { success, data: Client } | | `404` | Client not found | | ### PATCH /clients/{id} **Update client** The agency self-client (your own agency's record, used for internal hires) is readable but not editable over the API - PATCH returns 409, like DELETE. Operation ID: `updateClient` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `website` | string | no | Also refreshes the normalised domain. Adding a website to a client that was never enriched kicks off enrichment + the Client DNA first pass, same as providing it at create time (enrichment_status returns "pending"). | | `industry` | string \| null | no | | | `description` | string \| null | no | | | `locations` | array of string | no | | | `address` | string \| null | no | | | `notes` | string \| null | no | | | `logo_url` | string \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated client | { success, data: Client } | | `409` | The agency self-client cannot be edited through the API | | ### DELETE /clients/{id} **Delete client** Soft deletes a client. Jobs keep running; their client_id clears only if the row is hard-deleted (never over this API). Operation ID: `deleteClient` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { deleted, id } | | `404` | Client not found | | ### GET /clients/{id}/contacts **List client contacts** Operation ID: `listClientContacts` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Contacts at this client, primary first | { success, data: array of ClientContact, meta } | ### POST /clients/{id}/contacts **Add client contact** Lovelio resolves the person behind the contact: a matching email, LinkedIn URL or phone links the new contact to an existing person record. A person already on this client's contact list is returned as-is rather than duplicated (still 201). Operation ID: `addClientContact` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `email` | string | no | | | `phone` | string | no | Digits with an optional leading + country code; spaces, hyphens, dots and parentheses allowed. Rejected with a validation error otherwise. | | `title` | string | no | | | `reports_to` | string | no | Another contact (cct_ id) on this client this person reports to. Must belong to the same client. | | `is_primary` | boolean | no | | | `notes` | string | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Created contact | { success, data: ClientContact } | | `422` | Validation error - e.g. phone is not a valid phone number | | ### PATCH /clients/{id}/contacts/{contactId} **Update client contact** Patching name renames the person, not just this contact - the new name shows everywhere that person appears. Operation ID: `updateClientContact` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `contactId` | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `email` | string \| null | no | | | `phone` | string \| null | no | Digits with an optional leading + country code; spaces, hyphens, dots and parentheses allowed. Rejected with a validation error otherwise. | | `title` | string \| null | no | | | `reports_to` | string \| null | no | Another contact (cct_ id) on the same client this person reports to. Send null to clear. Cannot be the contact itself. | | `is_primary` | boolean | no | | | `notes` | string \| null | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Updated contact | { success, data: ClientContact } | | `422` | Validation error - e.g. phone is not a valid phone number | | ### DELETE /clients/{id}/contacts/{contactId} **Delete client contact** Soft deletes a contact. Any job pointing at it keeps the reference cleared by the composite FK rules on the job side. Operation ID: `deleteClientContact` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | | `contactId` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Deleted | { deleted, id } | | `404` | Contact not found | | ## Specs ### GET /specs **List specs** Specs (Floats in AU/NZ): speculative submissions - one anonymous candidate profile sent to one client contact with no job. Requires submissions:read. Operation ID: `listSpecs` **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 | | `candidate_id` | string | no | Filter to one candidate (cnd_ id). | | `client_id` | string | no | Filter to one client (cli_ id). | | `status` | string (`sent`, `viewed`, `interested`, `passed`, `converted`, `expired`, `revoked`) | no | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Paginated list of specs, newest first | { success, data: array of Spec, meta } | ### POST /specs **Create and send a spec** Sends a spec: a short personal email to the client contact with the pitch inline, a no-login link to the anonymous profile page, and the agency terms of business attached (when uploaded in Settings > Company). The candidate's name and current employer are scrubbed server-side from every client-facing field, and a candidate can never be specced to their own employer (422). Same write path as the in-product guided modal. Requires submissions:write. Operation ID: `createSpec` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `candidate_id` | string | yes | | | `client_id` | string | yes | | | `client_contact_id` | string | yes | Must belong to the client. | | `headline` | string | yes | Anonymous profile title, e.g. "Senior Quantity Surveyor - Tier 1 commercial". | | `pitch` | string | yes | Why this person is worth meeting. Employers generalised, never named. | | `chips` | array of string | no | Up to 6 short facts (years, sector, location). | | `why_client` | string | no | Why this candidate fits THIS client - shown on their private page. | | `email_subject` | string | yes | | | `email_greeting` | string | no | Opening line of the email, no trailing comma (e.g. "Hi Brett"). Defaults to "Hi {first name}". | | `email_body` | string | yes | Short personal note. The profile button and signature are added automatically. | | `attach_terms` | boolean, default true | no | | | `candidate_aware` | boolean, default false | no | The honest record: does the candidate know? | **Responses** | Code | Description | Body | | --- | --- | --- | | `201` | Created spec. email_sent false means the profile link works but the email failed - share it directly. | { success, data: Spec } | ### GET /specs/{id} **Get spec** Operation ID: `getSpec` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Spec with its response state and conversion links | { success, data: Spec } | | `404` | Spec not found | | ## Search ### POST /search **Natural language search** Natural language search across candidates, applications, jobs and interviews. `entity_type: candidates` runs the same faceted search core as POST /candidates/search and the in-app search bar, and returns that endpoint's body (see `CandidateSearchResult`); the other three entity types return structured rows with a plain-language interpretation (`EntitySearchResult`). Both shapes carry `entity_type`, so a client can branch on it. Results are always narrowed to what the API key may see. Operation ID: `nlSearch` **Request body** (`application/json`, required) | Field | Type | Required | Description | | --- | --- | --- | --- | | `query` | string | yes | Natural language search query | | `entity_type` | string (`candidates`, `applications`, `jobs`, `interviews`) | yes | The type of entity to search | | `limit` | integer, 1-100, default 25 | no | Max results per page | | `page` | integer, min 1, default 1 | no | Page number | | `sort_by` | string | no | Column to sort by (default: created_at). Not accepted for entity_type=candidates, which ranks by match quality - pass one and the request is rejected rather than silently ignored. | | `sort_order` | string (`asc`, `desc`), default "desc" | no | Not accepted for entity_type=candidates, as above. | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Search results | { success, data: CandidateSearchResult \| EntitySearchResult, meta } | | `422` | query or entity_type missing or invalid, or sort_by/sort_order passed with entity_type=candidates. | | | `500` | The search compiler is temporarily unavailable (candidates only). The API never degrades to a different matcher - retry. | | ## Business Development ### GET /bd/targets **List BD targets** The territory map: standing employer records in the agency's patch, scored 0-100 against the agency's own history (deterministic - nothing invented). Read-only; status moves only through the batch ops pursue_bd_target, dismiss_bd_target and convert_bd_target, which accept the bdt_ ids returned here. Operation ID: `listBdTargets` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`new`, `pursuing`, `dismissed`, `converted`) | no | | | `market` | string (`AU`, `UK`, `US`, `CA`) | no | | | `min_score` | integer | no | Only targets with lookalike_score at or above this value | | `limit` | integer, max 100, default 25 | no | | | `after` | string | no | Cursor from meta.next_cursor | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | BD targets | { success, data: array of BdTarget, meta } | ### GET /bd/targets/{id} **Get BD target** One territory target with its reason-to-call feed: the dated market signals, candidate-corpus signals and consultant-logged touches that accumulate on the target (newest first, up to 50). Operation ID: `getBdTarget` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | BD target with events | { success, data: BdTarget } | | `404` | Not found | | ### GET /bd/leads **List BD leads** The daily BD briefs: companies hiring right now that look like the agency's best clients. Read-only; leads resolve only through the batch ops dismiss_bd_lead and convert_bd_lead, which accept the bdl_ ids returned here. Lovelio never drafts or sends outreach - humans own the BD. Operation ID: `listBdLeads` **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`open`, `dismissed`, `converted`) | no | | | `kind` | string (`new_business`, `client_expansion`) | no | | | `limit` | integer, max 100, default 25 | no | | | `after` | string | no | Cursor from meta.next_cursor | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | BD leads | { success, data: array of BdLead, meta } | ### GET /bd/leads/{id} **Get BD lead** Operation ID: `getBdLead` **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | | **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | BD lead | { success, data: BdLead } | | `404` | Not found | | ### GET /bd/patch **Get BD patch** The agency's declared patch (its own words plus the AI's structured reading) and the territory state summary. Nulls mean the agency has not described its patch yet. Writing goes through the two-phase update_bd_patch batch op: call with { statement } for a preview, then { apply: true, statement, profile } to save. When statement is null, update_bd_patch { draft: true } returns data.draft { profile, territory, evidence } - a patch drafted from the enrichment already in the account, as role and sector chips to remove plus city/region/country territories to choose from. Neither the draft nor the preview writes anything. Operation ID: `getBdPatch` **Responses** | Code | Description | Body | | --- | --- | --- | | `200` | Patch and territory state | { success, data: object } | ## Schemas Objects referenced by the operations above. ### Account | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `company_name` | string | no | | | `slug` | string | no | | | `domain` | string \| null | no | | | `logo_url` | string \| null | no | | | `brand_colours` | array of string | no | | | `location` | string \| null | no | | | `tone_of_voice` | string \| null | no | | | `seats` | integer \| null | no | Paid seat count. Null while trialing (before first checkout). | | `plan_tier` | string (`premium`) | no | Legacy field. Lovelio has one plan with everything included, so this always reads premium. | | `plan_status` | string (`trialing`, `active`, `past_due`, `cancelled`) | no | | | `email_verified` | boolean | no | | | `status` | string | no | | | `website_url` | string \| null | no | | | `created_at` | string (date-time) | no | | | `enriched_at` | string (date-time) \| null | no | | | `brand_voice_prompt` | string \| null | no | | | `mission` | string \| null | no | Company mission - 1-2 sentences. Auto-extracted from the company website on signup; editable in the dashboard. | | `what_we_do` | string \| null | no | What problems the company solves and for whom - 1-2 sentences. Auto-extracted; editable. | | `why_join` | string \| null | no | Bullet list of reasons to work at the company. Auto-extracted; editable. | | `benefits` | string \| null | no | Bullet list of tangible benefits and perks. Auto-extracted; editable. Job ad generation never invents items not in this block. | | `company_values` | string \| null | no | Bullet list of cultural anchors / values that define how the team works. | | `role_intelligence` | object \| null | no | Operational role data. Known keys: rolesWePlaceInput (string), rolesWePlace (object mapping specialisation/group name to bullet list of role titles), roleIntelligence (object mapping role title to { salaryRange, keySkills[], seniority[] }), roleTemplates (object mapping role title to JD template text). | | `onboarding_completed_at` | string (date-time) \| null | no | Set when the account holder finishes the in-app onboarding wizard. Null means the next dashboard visit will force the wizard. | | `onboarding_answers` | object | no | Raw answers captured by the onboarding wizard. Keys: logoConfirmed (boolean). | | `timezone` | string | no | IANA timezone used for company-wide scheduled work like the weekly digest. Defaults to UTC; automatically populated from Brandfetch / Firecrawl / the first admin browser on signup. | | `timezone_source` | string (`default`, `manual`, `brandfetch`, `firecrawl`, `browser`) | no | How the timezone was set. "manual" means an admin or API partner set it explicitly and no automated process will overwrite it. | | `is_demo` | boolean | no | True for throwaway demo accounts created by the MagicHire team. Always false for real customer accounts created via /v1/accounts/signup. | ### Activity | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `resource_type` | string (`job`, `job_ad`, `candidate`, `application`, `interview`, `placement`) | no | | | `resource_id` | string | no | | | `action` | string | no | | | `activity_type` | string (`created`, `updated`, `note_added`, `deleted`, `stage_changed`, `applied`, `assessment_completed`, `automation_ran`, `email_sent`, `resume_parsed`, `interview_scheduled`, `interview_cancelled`, `placement_created`, `placement_status_changed`, `references_requested`, `references_received`, `call_logged`) | no | | | `actor_type` | string (`user`, `bot`, `system`, `automation`, `api`) | no | | | `actor_id` | string \| null | no | | | `actor_email` | string \| null | no | | | `description` | string | no | | | `metadata` | object | no | Activity-specific data (scores, field changes, etc.) | | `created_at` | string (date-time) | no | | ### ApiKey | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `name` | string | no | | | `prefix` | string | no | | | `scopes` | array of string | no | | | `status` | string | no | | | `key_type` | string | no | | | `last_used_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | no | | ### Application | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `job_id` | string \| null | no | | | `job_title` | string \| null | no | | | `candidate_id` | string | no | | | `candidate_name` | string \| null | no | | | `candidate_email` | string \| null | no | | | `candidate_phone` | string \| null | no | | | `candidate_location` | object \| null | no | Coarsened to suburb (or city) and state - never a street address. | | `candidate_location.city` | string | no | | | `candidate_linkedin_url` | string \| null | no | | | `candidate_cv_url` | string \| null | no | | | `candidate_skills` | array of string | no | | | `current_title` | string \| null | no | Snapshot of the candidate current_title at the moment they applied. Never updated after insert. | | `current_company` | string \| null | no | Snapshot of the candidate current_company at the moment they applied. Never updated after insert. | | `stage` | string (`funnel`, `maybe`, `yes`, `no`, `screen`, `submitted`, `client_interview`, `references`, `offer`, `placed`, `rejected`, `withdrew`) | no | | | `status` | string | no | Deprecated alias for stage. Use stage instead. | | `source` | string \| null | no | Canonical source category: Careers Page, Job Board, Referral, Talent Pool, Database Search, Social, Agency, Direct, Imported, or Other. | | `source_detail` | string \| null | no | Granular sub-source within the category - the exact board, referrer, pool, or search query. | | `assessment` | object \| null | no | | | `assessment.job_fit_score` | number, 0-100 | no | | | `assessment.hm_fit_score` | number, 0-100 | no | | | `assessment.overall_recommendation` | string (`strong_hire`, `hire`, `hold`, `talent_pool`, `reject`) | no | | | `assessment.summary` | string \| null | no | | | `assessment.alternative_teams` | array of string | no | | | `assessment.alternative_roles` | array of string | no | | | `assessment.criteria_scores` | array of object | no | | | `assessment.criteria_scores[].criterion` | string \| null | no | | | `assessment.criteria_scores[].block` | string \| null | no | | | `assessment.criteria_scores[].scoring_type` | string \| null | no | | | `assessment.criteria_scores[].pass` | boolean \| null | no | | | `assessment.criteria_scores[].scale_score` | number \| null | no | | | `assessment.criteria_scores[].ai_reasoning` | string \| null | no | | | `assessment.version` | integer | no | | | `assessment.assessed_at` | string (date-time) \| null | no | | | `parsed_profile` | ParsedProfile | no | | | `notes` | string \| null | no | | | `shortlist_summary` | string \| null | no | AI-generated 150-word briefing for the hiring manager. Populated when the application is triaged to yes. | | `shortlist_summary_generated_at` | string (date-time) \| null | no | | | `reference_summary` | string \| null | no | AI-consolidated summary across all referee responses. Populated when the final expected referee has submitted their form. | | `reference_flags` | array of object \| null | no | Structured red flags detected in referee responses. Medium/high severity flags automatically enqueue a reference_red_flag review. | | `reference_flags[].type` | string (`integrity`, `performance`, `interpersonal`, `other`) | no | | | `reference_flags[].referee` | string | no | | | `reference_flags[].quote` | string | no | | | `reference_flags[].severity` | string (`low`, `medium`, `high`) | no | | | `reference_summary_generated_at` | string (date-time) \| null | no | | | `expected_reference_count` | integer | no | How many referees Lovelio waits for before generating the reference summary. Defaults to 2 and is overwritten when the candidate submits their contacts. | | `referee_details_submitted_at` | string (date-time) \| null | no | When the candidate submitted their referee contacts through the Request References form. | | `applied_at` | string (date-time) | no | | | `status_updated_at` | string (date-time) | no | | ### BdLead A daily BD brief: a company hiring right now that looks like the agency's best clients, with the agency's own history as the proof. Briefs only - Lovelio never drafts or sends outreach. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `employer_name` | string | no | | | `market` | string (`AU`, `UK`, `US`, `CA`, `OTHER`) | no | OTHER means the lead was captured by hand from an ad outside the markets Lovelio sweeps; it carries no currency. | | `kind` | string (`new_business`, `client_expansion`) | no | | | `status` | string (`open`, `dismissed`, `converted`) | no | | | `score` | integer | no | | | `fit_score` | integer \| null | no | | | `signals` | array of string | no | | | `why_now` | string \| null | no | | | `why_you` | string \| null | no | | | `fit_note` | string \| null | no | | | `est_fee` | number \| null | no | | | `fee_basis` | string \| null | no | | | `currency` | string \| null | no | | | `matched_client_id` | string \| null | no | | | `surfaced_on` | string (date) | no | | | `dismissed_at` | string (date-time) \| null | no | | | `converted_at` | string (date-time) \| null | no | | | `converted_client_id` | string \| null | no | | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### BdTarget One employer on the agency's territory map, scored deterministically against the agency's own placement and job history. The human owns the status; the scorer only refreshes score and evidence. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `employer_name` | string | no | | | `market` | string (`AU`, `UK`, `US`, `CA`) | no | | | `status` | string (`new`, `pursuing`, `dismissed`, `converted`) | no | | | `lookalike_score` | integer | no | 0-100, deterministic fit against the agency's history | | `right_to_win` | string \| null | no | One line: why this agency in particular | | `score_facts` | object | no | The evidence behind the score (worked families, salary bands, ad facts) | | `matched_client_id` | string \| null | no | | | `pursued_at` | string (date-time) \| null | no | | | `dismissed_at` | string (date-time) \| null | no | | | `converted_at` | string (date-time) \| null | no | | | `converted_client_id` | string \| null | no | | | `last_scored_at` | string (date-time) | no | | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### CalendarEvent | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `title` | string | no | | | `notes` | string \| null | no | | | `event_type` | string (`interview`, `phone_screen`, `reminder`, `deadline`, `meeting`) | no | | | `starts_at` | string (date-time) | no | | | `ends_at` | string (date-time) \| null | no | | | `all_day` | boolean | no | | | `application_id` | string \| null | no | | | `job_id` | string \| null | no | | | `candidate_id` | string \| null | no | | | `assigned_to` | string \| null | no | User ID the event is assigned to. | | `created_by` | string \| null | no | | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### Candidate | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `first_name` | string | no | | | `last_name` | string | no | | | `name` | string | no | | | `email` | string (email) \| null | no | | | `phone` | string \| null | no | | | `location` | object \| null | no | Where the candidate lives, coarsened to suburb (or city) and state - "Balmain, NSW". Never a street address, whatever precision the record was captured at. | | `location.city` | string | no | | | `linkedin_url` | string \| null | no | | | `current_title` | string \| null | no | Current job title. Auto-populated from parsed CV; editable on the candidate record. Indexed and searchable. PATCH accepts current_title or current_job_title interchangeably. | | `current_company` | string \| null | no | Current employer. Auto-populated from parsed CV; editable on the candidate record. Indexed and searchable. PATCH accepts current_company or current_employer interchangeably. | | `years_experience` | integer \| null | no | Estimated years of experience from work history. | | `skills` | array of string | no | | | `education` | array of object | no | | | `education[].degree` | string \| null | no | | | `education[].institution` | string | no | | | `education[].year` | integer \| null | no | | | `parsed_profile` | ParsedProfile | no | | | `cv_url` | string \| null | no | URL to the uploaded CV document. | | `status` | string | no | | | `availability` | string (`unknown`, `available`, `passive`, `not_available`) | no | | | `source` | string \| null | no | How the candidate entered the system, e.g. 'bulk_import' for candidates created by a CV import. | | `tags` | array of string | no | Flat labels the agency put on this candidate, alphabetical. Tags are deduplicated case-insensitively, so the label comes back with the spelling the tag was first created with. | | `do_not_approach` | boolean | no | Off-limits flag. Flagged candidates are excluded from search, matching and suggestions. | | `contact_only` | boolean | no | True while this person exists only as a client contact: searchable, but not yet in a candidate process. Flips false the moment they enter one (application, talent pool, or a deliberate candidate create). | | `rating` | integer, 1-5 \| null | no | The agency's 1-5 star rating on the candidate. Null = not yet rated. | | `open_to_work` | boolean \| null | no | Open-to-work flag, from a LinkedIn sighting or a consultant's toggle. Null = never captured either way. | | `salary_expectation` | string \| null | no | Captured salary expectation, verbatim ("145k + super"). Null = never captured. | | `salary_expectation_annual` | number \| null | no | Normalized annual figure derived from salary_expectation, when parseable. | | `salary_currency` | string \| null | no | Currency code when the expectation stated one (AUD, USD, GBP, EUR, NZD). | | `notice_period` | string \| null | no | Captured notice period, verbatim ("4 weeks"). | | `notice_period_days` | integer \| null | no | Normalized days derived from notice_period (0 = immediately available). | | `work_rights` | string \| null | no | Captured right-to-work statement, verbatim ("Australian citizen"). | | `work_rights_status` | string (`citizen`, `permanent_resident`, `visa_holder`, `requires_sponsorship`) \| null | no | Normalized status derived from work_rights. | | `salary_expectation_amount` | number \| null | no | Amount in the candidate's own period derived from salary_expectation (850 for "850/day"). | | `salary_expectation_period` | string (`year`, `day`, `hour`) \| null | no | Period derived from salary_expectation. Day and hour rates never fabricate an annual figure. | | `preferred_roles` | array of string \| null | no | Roles the candidate said they want next. Null = never captured. | | `preferred_seniority` | string (`intern`, `junior`, `mid`, `senior`, `staff`, `lead`, `principal`, `head`, `director`, `vp`, `c_suite`) \| null | no | Seniority level the candidate wants. | | `work_types` | array of string (`permanent`, `contract`, `temp`, `part_time`) \| null | no | Work types the candidate is open to. | | `open_to_relocation` | boolean \| null | no | Whether the candidate said they would relocate. Null = never captured. | | `available_from` | string (date) \| null | no | Stated availability date. Complements notice_period in availability search. | | `preferred_locations` | array of string \| null | no | Places the candidate said they want to work. Gazetteer-resolved for search. | | `applications` | array of object | no | Summary of all applications for this candidate. | | `applications[].job_id` | string \| null | no | | | `applications[].job_title` | string \| null | no | | | `applications[].application_id` | string | no | | | `applications[].status` | string | no | | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | | `enriched_at` | string (date-time) \| null | no | | ### CandidateImport | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `status` | string (`uploading`, `processing`, `complete`, `cancelled`) | no | | | `file_count` | integer | no | | | `created_at` | string (date-time) \| null | no | | | `completed_at` | string (date-time) \| null | no | | | `counts` | object | no | | | `counts.pending` | integer | no | | | `counts.processing` | integer | no | | | `counts.imported` | integer | no | New candidates created. | | `counts.merged` | integer | no | Existing candidates updated (matched by email). | | `counts.failed` | integer | no | Files that could not be read. | | `failures` | array of object | no | | | `failures[].filename` | string | no | | | `failures[].reason` | string | no | Human-readable reason the file was skipped. | | `role_matches` | array of object \| null | no | How the imported candidates compare with the open roles - embedding similarity only, no assessments. Null until matching has run. | | `role_matches[].job_id` | string | no | | | `role_matches[].job_title` | string | no | | | `role_matches[].strong_count` | integer | no | | | `role_matches[].matches` | array of object | no | | | `role_matches[].matches[].candidate_id` | string | no | | | `role_matches[].matches[].name` | string | no | | | `role_matches[].matches[].score` | integer | no | 0-100 similarity to the role description. | ### CandidateSearchResult What the faceted search core returns for a candidate query. Identical for POST /candidates/search and POST /search with entity_type=candidates - the two doors run the same function. | Field | Type | Required | Description | | --- | --- | --- | --- | | `entity_type` | string (`candidates`) | no | Present on POST /search responses so a client can branch; absent from POST /candidates/search, which only ever searches candidates. | | `interpretation` | object | no | How the engine read the query. | | `interpretation.intent` | string \| null | no | The soft ask used for semantic ranking. | | `interpretation.facets` | array of object | no | | | `interpretation.facets[].label` | string | no | Plain-English label, as shown on the in-app chips. | | `interpretation.facets[].kind` | string | no | What the facet checks (role, skill, location, employer, ...). "unsupported" means the engine understood the ask but cannot filter on it yet. | | `interpretation.facets[].strength` | string (`must`, `nice`, `exclude`) | no | must filters, nice ranks, exclude removes. | | `total_count` | integer | no | Real database count of candidates matching every hard requirement. | | `ranked_pool_size` | integer | no | How many of those were ranked - the depth pagination can reach. | | `page` | integer | no | | | `page_size` | integer | no | | | `has_more` | boolean | no | | | `notes` | array of object | no | Anything the engine could not do properly (a degraded plan, a truncated scan). An empty array means everything ran as interpreted. | | `notes[].code` | string | no | | | `notes[].message` | string | no | | | `results` | array of object | no | | | `results[].id` | string | no | External candidate id (cnd_...). | | `results[].name` | string \| null | no | | | `results[].email` | string \| null | no | | | `results[].location` | string \| null | no | | | `results[].current_job_title` | string \| null | no | | | `results[].current_employer` | string \| null | no | | | `results[].match_score` | integer | no | The engine's deterministic feature score, 0-100. Decides the order. | | `results[].band` | string (`strong`, `good`, `partial`) | no | Requirement-coverage band. | | `results[].coverage` | object | no | Requirements this candidate evidences, out of the total asked. | | `results[].coverage.matched` | integer | no | | | `results[].coverage.total` | integer | no | | | `mentioned_in_cv` | object | no | People who did NOT match, kept separate. Present only when the query names a company and somebody's CV mentions it while their employment history does not. Never add this `total_count` to the top-level one: measured on a 9,378-candidate book, roughly half of these people did work at the company and it was never captured, and roughly half never worked there at all ("founded by ex-McKinsey partners", "in competition with Bain"). They carry no match_score or band because there is nothing to score. Only ever produced for employer requirements - a company name is a rare distinctive token in prose, a job title is not. | | `mentioned_in_cv.total_count` | integer | no | Real database count of the whole tier, which can exceed the rows returned. | | `mentioned_in_cv.facet_labels` | array of string | no | Which interpretation facets these people reached by a CV mention rather than by employment history. | | `mentioned_in_cv.results` | array of object | no | | | `mentioned_in_cv.results[].id` | string | no | | | `mentioned_in_cv.results[].name` | string \| null | no | | | `mentioned_in_cv.results[].email` | string \| null | no | | | `mentioned_in_cv.results[].location` | string \| null | no | | | `mentioned_in_cv.results[].current_job_title` | string \| null | no | | | `mentioned_in_cv.results[].current_employer` | string \| null | no | | ### Client A company the agency recruits for - a record inside the account, never a tenant. Creating one with a website kicks off AI enrichment (logo, industry, locations) and an automatic first-pass Client DNA build from the client's public footprint. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `name` | string | no | | | `website` | string \| null | no | | | `domain` | string \| null | no | Normalised bare domain, e.g. "acme.com". Derived from website. | | `logo_url` | string \| null | no | | | `industry` | string \| null | no | | | `description` | string \| null | no | | | `locations` | array of string | no | | | `address` | string \| null | no | One-line postal address, shown on the client's terms document. | | `fee_schedule` | object | no | The fee terms in force: the client's own terms, or the agency standard (is_standard: true). Bands are slabs - the whole salary is charged at the rate of the band it lands in. Changing a client's terms goes through the set_client_fee_terms action (admin-only), never a PATCH. This is the agency's price list: the whole object is null without the clients:financials:read scope. | | `fee_schedule.currency` | string | no | | | `fee_schedule.fee_basis` | string (`package`, `base`) | no | What the percent applies to: total salary package or base salary. | | `fee_schedule.tiers` | array of object | no | Slab bands, ascending. Salaries from `from` upward (until the next band) pay `percent` on the whole salary. | | `fee_schedule.tiers[].from` | number | no | | | `fee_schedule.tiers[].percent` | number | no | | | `fee_schedule.minimum_fee` | number \| null | no | | | `fee_schedule.guarantee_days` | integer \| null | no | | | `fee_schedule.guarantee_remedy` | string (`replacement`, `refund_full`, `refund_pro_rata`, `refund_half`, `replacement_or_refund_half`, `none`) | no | | | `fee_schedule.guarantee_notes` | string \| null | no | | | `fee_schedule.payment_terms_days` | integer \| null | no | | | `fee_schedule.is_standard` | boolean | no | True when the client is on the agency standard (no approved variation). | | `fee_schedule.version` | integer | no | Schedule version - increments on every approved change. | | `notes` | string \| null | no | | | `enrichment_status` | string (`none`, `pending`, `complete`, `failed`) | no | Website enrichment progress. "none" for clients created without a website. | | `dna_status` | string (`none`, `building`, `complete`, `failed`) | no | Client DNA build progress. Assessments for this client's jobs use the DNA once complete. | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### ClientContact A person at the client: hiring manager, HR, finance. Contacts receive submissions and sit on jobs, always scoped to their client. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `client_id` | string | no | | | `person_id` | string \| null | no | The person behind this contact - a candidate id. One human can hold both a candidate record and contact roles; this is the same id GET /candidates serves them under. | | `name` | string | no | The person's name. It lives on the person record and is shared across every role they hold, so renaming it here renames them everywhere. | | `email` | string \| null | no | | | `phone` | string \| null | no | | | `title` | string \| null | no | | | `reports_to` | string \| null | no | Another contact (cct_ id) on the same client this person reports to. Builds the client org chart. | | `is_primary` | boolean | no | One primary per client; setting a new primary demotes the old one. | | `origin` | string (`relationship`, `works_here`) | no | "relationship" - somebody put this person on the client, and they are who the agency deals with. "works_here" - derived automatically because the person's own employment record says they work there; these are listed separately in the app, carry no work email, and are never the recipient of a submission or an interview invitation. A consultant promotes one to "relationship" by hand. | | `notes` | string \| null | no | | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### DistributionBoard | Field | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | no | | | `name` | string | no | | | `regions` | array of string | no | | | `industries` | array of string | no | | | `pricing` | string (`free`, `paid`, `mixed`) | no | | | `auth_type` | string (`none`, `api_key`, `oauth2`, `basic`) | no | | | `capabilities` | object | no | | | `capabilities.edit` | boolean | no | | | `capabilities.expire` | boolean | no | | | `capabilities.applyBack` | boolean | no | | | `capabilities.statusQuery` | boolean | no | | | `monthly_post_limit` | integer \| null | no | Your monthly posting cap for this board (null = unlimited). Set in Settings; enforced atomically at publish - a post past the cap is rejected with error code quota_exceeded. | | `posts_used_this_month` | integer | no | Posts consumed on this board in the current calendar month (company timezone). | | `posts_remaining_this_month` | integer \| null | no | Posts left this month (null = unlimited). Never negative. | ### DistributionRule | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `name` | string | no | | | `filter` | object | no | Every set field must equal the job's value; an empty filter matches every job. country is an ISO 3166 alpha-2 code (a country name is normalised to one on write). job_type is the contract basis: Permanent, Contract or Temporary. team_id is the job's team; category is the older free-text form of the same axis, matched against the team name. | | `filter.country` | string | no | | | `filter.category` | string | no | | | `filter.job_type` | string (`Permanent`, `Contract`, `Temporary`) | no | | | `filter.team_id` | string | no | | | `target_board_slugs` | array of string | no | | | `active` | boolean | no | | | `ai_screen_enabled` | boolean | no | Per-job AI screening. When true, every inbound applicant whose AI verdict lands yes or maybe is automatically invited to the AI voice screen. Candidates a consultant adds by hand are never auto-invited. Set via the set_ai_screening batch op. | | `ai_screen_focus` | array of string \| null | no | Consultant-set focus areas the AI screen digs into. Feeds the per-candidate question generator. | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### Document A file attached to any record. Documents are polymorphic: the same endpoints serve candidates, clients, contacts, jobs, placements, talent pools and more. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `record_type` | string (`candidate`, `client`, `client_contact`, `job`, `job_ad`, `application`, `interview`, `placement`, `submission`, `spec`, `spec_submission`, `bd_target`, `talent_pool`, `team`, `hiring_manager`) | no | The kind of record this document is filed against. | | `record_id` | string | no | External ID of the record it belongs to. | | `file_name` | string | no | | | `content_type` | string | no | | | `size_bytes` | integer \| null | no | | | `source` | string (`upload`, `migration`, `cv`) | no | How it got here: 'upload' a person added it, 'migration' it came from a previous ATS, 'cv' it is the file the candidate's CV pointer resolves to. | | `is_cv` | boolean | no | Candidates only: this is the current CV, the file the resume parser and cv_url read. | | `uploaded_by_email` | string \| null | no | | | `source_created_at` | string (date-time) \| null | no | The date the document had in the system it came from, when known. Imported documents sort by this, not by the day the import ran. | | `created_at` | string (date-time) | no | | ### EmailTemplate Merged view of a canonical email template and its per-company override (if any). | Field | Type | Required | Description | | --- | --- | --- | --- | | `template_key` | string | no | | | `name` | string | no | | | `category` | string (`candidate`, `hiring_manager`, `referee`) | no | | | `trigger` | string | no | | | `description` | string | no | | | `variables` | array of string | no | | | `default_instructions` | string | no | | | `ai_instructions` | string \| null | no | Per-company override of the default_instructions. null when no override exists. | | `enabled` | boolean | no | Whether Lovelio should send this template. Defaults to true. | | `has_override` | boolean | no | | | `updated_at` | string (date-time) \| null | no | | ### 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 | | ### Form | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `name` | string | no | | | `category` | string (`screening`, `assessment`, `interview`, `decision`, `offer`, `references`, `onboarding`, `compliance`) | no | | | `description` | string \| null | no | | | `ai_augmentation_enabled` | boolean | no | | | `is_default` | boolean | no | | | `question_count` | integer | no | | | `created_at` | string (date-time) | no | | ### FormInstance | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `form_id` | string | no | | | `instance_type` | string (`screening`, `reference`) | no | | | `status` | string (`draft`, `sent`, `opened`, `started`, `completed`) | no | | | `job_application_id` | string \| null | no | | | `referee_id` | string \| null | no | | | `sent_at` | string (date-time) \| null | no | | | `opened_at` | string (date-time) \| null | no | | | `completed_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | no | | ### FormQuestion | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `order_index` | integer | no | | | `question_text` | string | no | | | `question_type` | string (`text`, `rating`, `yes_no`, `multiple_choice`) | no | | | `options` | array of string \| null | no | | | `scoring_weight` | number \| null | no | | | `flag_mode` | string (`red`, `green`, `neutral`) \| null | no | | | `required` | boolean | 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 | | ### Job | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `title` | string | no | | | `status` | string (`active`, `on_hold`, `closed`, `filled`) | no | Jobs default to active on creation. Move freely between active, on_hold, and closed. filled is set automatically when a hire is accepted. | | `client` | object \| null | no | The client this job is for. Every job created in-product gets one; jobs created via this API may omit it. | | `client.id` | string | no | | | `client.name` | string \| null | no | | | `client_contact` | object \| null | no | The contact at the client for this job. Always belongs to the job's client. | | `client_contact.id` | string | no | | | `client_contact.name` | string \| null | no | | | `team` | object \| null | no | | | `team.id` | string | no | | | `team.name` | string \| null | no | | | `hiring_manager` | object \| null | no | | | `hiring_manager.id` | string | no | | | `hiring_manager.name` | string \| null | no | | | `location` | object \| null | no | Structured job location. `type` is the workplace model. `city`, `country`, and `country_code` come from a Google Places city pick and are null for fully-remote roles. | | `location.type` | string (`on_site`, `hybrid`, `remote`) | no | | | `location.city` | string \| null | no | | | `location.country` | string \| null | no | | | `location.country_code` | string \| null | no | ISO 3166-1 alpha-2 country code | | `employment_type` | string (`full_time`, `part_time`, `contract`) \| null | no | | | `compensation` | object | no | | | `compensation.base_min` | number \| null | no | | | `compensation.base_max` | number \| null | no | | | `compensation.ote` | number \| null | no | | | `compensation.currency` | string | no | | | `compensation.period` | string (`year`, `day`, `hour`) | no | How base_min/base_max are quoted. Contract and temp roles default to hour, everything else to year; day is only ever set explicitly. Jobs created before 6.2.0 report year. | | `description` | string \| null | no | | | `spec` | object \| null | no | | | `spec.summary` | string | no | | | `spec.responsibilities` | array of string | no | | | `spec.requirements` | array of string | no | | | `spec.nice_to_have` | array of string | no | | | `assessment_criteria` | array of object | no | AI-generated criteria for assessing candidates. | | `interview_plan` | object \| null | no | AI-generated interview plan. | | `job_ad` | string \| null | no | Associated job ad ID. | | `share_links` | object \| null | no | Shareable URLs for the job. | | `stats` | object \| null | no | Application statistics. List responses carry { total_applications }; GET /jobs/{id} adds one count per pipeline stage. | | `target_start_date` | string (date) \| null | no | | | `compensation_research` | object \| null | no | Market compensation research data. | | `default_interview_format` | string (`video`, `phone`, `in_person`, `technical`), default "video" | no | Default interview format used when scheduling. "video" auto-injects a Google Meet link when the interviewer has Google Calendar connected. "phone" puts the interviewer phone in the event description. "in_person" puts the job location in the description. | | `is_evergreen` | boolean, default false | no | Always-open pipeline job ("people we always need"). Works like any open job (matching, ads, applications) but is excluded from performance metrics: billing forecast, client fill rate and momentum, active-job analytics, and no-applicant nudges. Settable on create and via PATCH. | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### JobBoardPosting | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `job_id` | string \| null | no | | | `job_ad_id` | string \| null | no | | | `board_slug` | string | no | | | `status` | string (`pending`, `publishing`, `live`, `expired`, `rejected`, `error`) | no | | | `url` | string \| null | no | | | `external_id` | string \| null | no | | | `error_code` | string \| null | no | | | `error_message` | string \| null | no | | | `published_at` | string (date-time) \| null | no | | | `updated_at` | string (date-time) | no | | ### MarketplaceDeal The contract moment of a split: an immutable snapshot written when an intro is accepted (split, fee basis, salary band, terms version, revealed identity, 12-month introduction protection), then the SHARED money record once the placement lands - both agencies read identical numbers here. Lovelio takes no fee, moves no money, and arbitrates no disputes; the deal makes the numbers unambiguous. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `intro_id` | string \| null | no | | | `listing_id` | string \| null | no | | | `listing_kind` | string (`job`, `candidate`) | no | | | `listing_headline` | string | no | | | `status` | string (`connected`, `in_progress`, `placed`, `dead`) | no | Derived from the real pipeline: client-side stages read as in_progress, a placement as placed, a dead application kills an unplaced deal. | | `role` | string (`owner`, `partner`) | no | Your side of this deal: owner listed it, partner made the intro. | | `counterpart_agency` | string | no | The other agency on the deal. | | `owner_share_percent` | integer (`50`, `60`, `70`, `80`) | no | | | `your_share_percent` | integer | no | | | `fee_percent` | number \| null | no | | | `salary_min` | number \| null | no | | | `salary_max` | number \| null | no | | | `currency` | string \| null | no | | | `terms_version` | string | no | The Split Terms version both agencies accepted. | | `revealed` | object \| null | no | The identity snapshot written once at accept: candidate name and contact, plus both consultants' contacts. | | `revealed_at` | string (date-time) \| null | no | | | `protection_ends_at` | string (date-time) | no | The 12-month introduction protection window. | | `placement_id` | string \| null | no | The owner's placement record (pla_). Null for the partner - the deal itself carries the shared money snapshot. | | `placement_status` | string (`pending_start`, `started`, `fell_off`, `completed`, null) \| null | no | | | `placed_salary` | number \| null | no | Requires the marketplace:financials:read scope - null without it. | | `placed_fee_amount` | number \| null | no | Requires the marketplace:financials:read scope - null without it. | | `placed_currency` | string \| null | no | The placement's real currency - render placed money in this. `currency` stays the listing's asking currency. | | `partner_share_amount` | number \| null | no | Requires the marketplace:financials:read scope - null without it. | | `your_share_amount` | number \| null | no | Your side of the placed fee, per the locked split. Requires the marketplace:financials:read scope - null without it. | | `placed_start_date` | string (date) \| null | no | | | `owner_marked_paid_at` | string (date-time) \| null | no | The paid/received handshake: the placing agency's stamp. | | `partner_marked_received_at` | string (date-time) \| null | no | The paid/received handshake: the receiving agency's stamp. | | `closed_at` | string (date-time) \| null | no | Set when an unplaced deal was deliberately closed. | | `closed_by` | string (`you`, `counterpart`, null) \| null | no | Which side closed it, from your point of view. | | `closed_reason` | string \| null | no | | | `created_at` | string (date-time) | no | | ### MarketplaceIntro A candidate submitted to a job listing, or a request on a candidate listing. Reveal-on-accept: until the listing owner accepts, the intro carries only the scrubbed anonymous profile - identity exists nowhere the other tenant can read. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `listing_id` | string \| null | no | | | `listing_headline` | string | no | | | `kind` | string (`submission`, `request`) | no | | | `direction` | string (`received`, `sent`) | no | received = an intro on your listing; sent = an intro your agency made. | | `status` | string (`pending`, `accepted`, `declined`, `withdrawn`, `expired`) | no | | | `from_agency` | object | no | | | `from_agency.name` | string | no | | | `from_agency.consultant` | string \| null | no | | | `candidate_id` | string \| null | no | Your own candidate record behind an intro you SENT (cnd_). Always null on received intros - reveal happens on the deal, not the intro. | | `anonymous_profile` | object \| null | no | | | `anonymous_profile.headline` | string | no | | | `anonymous_profile.chips` | array of string | no | | | `anonymous_profile.pitch` | string | no | | | `note` | string \| null | no | | | `fit_summary` | string \| null | no | AI facts-to-check against your listing (received submissions only). Facts, never verdicts. | | `attested_relationship` | boolean | no | The submitter attested they interviewed the candidate and the candidate agreed to be represented. | | `decline_reason` | string \| null | no | | | `prior_knowledge` | boolean \| null | no | Stamped at accept time: whether the receiving agency already knew the candidate (checked BEFORE the reveal). | | `responded_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | no | | ### MarketplaceListing An anonymised job or candidate on the cross-agency split marketplace. The client or candidate is never identified; the posting agency always is. Browse serves only these pre-scrubbed snapshots - never another tenant's source records. Listings hard-expire after 30 days (renewal re-attests the listing is still real). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `kind` | string (`job`, `candidate`) | no | | | `status` | string (`live`, `paused`, `filled`, `expired`, `withdrawn`) | no | | | `headline` | string | no | Anonymised headline, e.g. "Senior auditor, top-tier firm, Melbourne". | | `chips` | array of string | no | Up to 6 anonymised fact chips. | | `pitch` | string | no | The anonymised pitch. Deterministically scrubbed and moderated before it crosses tenants. | | `location` | string \| null | no | | | `agency` | object | no | The posting agency - always visible (anonymity protects clients and candidates, never the trading agencies). | | `agency.name` | string | no | | | `agency.location` | string \| null | no | | | `salary_min` | number \| null | no | | | `salary_max` | number \| null | no | | | `currency` | string \| null | no | | | `fee_percent` | number \| null | no | The declared fee basis (percent of first-year salary). | | `owner_share_percent` | integer (`50`, `60`, `70`, `80`) | no | The split the owner keeps. Lovelio takes 0% - the fee is split between the two agencies, full stop. | | `partner_share_percent` | integer | no | Always 100 - owner_share_percent. | | `mine` | boolean | no | True when your agency owns this listing. | | `source_job_id` | string \| null | no | The job behind your own listing (job_). Always null on other agencies' listings. | | `source_candidate_id` | string \| null | no | The candidate behind your own listing (cnd_). Always null on other agencies' listings. | | `expires_at` | string (date-time) | no | | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) \| null | 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 | | ### Outreach A message to a candidate that the agency recorded. Lovelio drafts outreach and stores what you tell it you sent; it never sends on your behalf. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `candidate_id` | string | no | | | `job_id` | string \| null | no | | | `platform` | string (`email`, `linkedin`, `whatsapp`) | no | | | `subject` | string \| null | no | Email only. | | `body` | string | no | | | `status` | string (`draft`, `sent`, `replied`, `no_response`) | no | | | `sent_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | no | | ### Placement The agency's win: a candidate placed with a client for a fee. Created against an application (moves it to placed) or logged directly with candidate_id + client_id. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `candidate_id` | string | no | | | `candidate_name` | string \| null | no | The candidate's name, embedded so list consumers never need a lookup per row. | | `client_id` | string | no | | | `client_name` | string \| null | no | The client's name, embedded so list consumers never need a lookup per row. | | `job_id` | string \| null | no | | | `job_title` | string \| null | no | | | `application_id` | string \| null | no | | | `salary` | number \| null | no | First-year salary. Requires the placements:financials:read scope - null without it. | | `currency` | string \| null | no | ISO 4217 code the placement was billed in. Fixed at write time and never re-derived - changing an agency or user default does not relabel existing placements. | | `fee` | object | no | The placement fee. amount is the single billing figure: for percent fees it is salary * percent / 100, computed at write time; for fixed fees it is the amount as entered. The whole object is null without the placements:financials:read scope. | | `fee.type` | string (`percent`, `fixed`) | no | | | `fee.percent` | number \| null | no | | | `fee.amount` | number \| null | no | | | `placement_type` | string (`permanent`, `contract`) | no | Perm or contract. Defaults from the job's employment_type on create. A perm placement's value is its fee amount; a contract placement's value is its expected GP. | | `offer_date` | string (date) \| null | no | When the offer was accepted. Defaults to the day the placement is logged. | | `contract` | object \| null | no | Contract terms - present only when placement_type is contract. expected_gp defaults to margin (charge_rate - pay_rate) times the working units between start_date and end_date, and can be set directly; an explicit value always wins. | | `contract.end_date` | string (date) \| null | no | | | `contract.charge_rate` | number \| null | no | Requires the placements:financials:read scope - null without it. | | `contract.pay_rate` | number \| null | no | Requires the placements:financials:read scope - null without it. | | `contract.rate_period` | string (`hourly`, `daily`, `weekly`) \| null | no | | | `contract.expected_gp` | number \| null | no | Expected gross profit over the initial term, in the placement's currency. Requires the placements:financials:read scope - null without it. | | `credits` | array of object | no | The credit split: who this win counts for, at what percent. Always totals 100. Defaults to the placement owner at 100%. user_id is a workspace user id. WHO is credited always comes through; `percent` is null without the placements:financials:read scope. | | `credits[].user_id` | string | no | | | `credits[].role` | string (`candidate_consultant`, `job_owner`, `business_development`, `other`) \| null | no | | | `credits[].percent` | number | no | | | `start_date` | string (date) \| null | no | | | `guarantee_days` | integer \| null | no | Guarantee period in days from start_date. Defaults from the client's fee schedule when omitted on create. | | `status` | string (`pending_start`, `started`, `fell_off`, `completed`) | no | Lifecycle: pending_start until the candidate starts, started while inside (or past) the guarantee, fell_off when they leave during the guarantee, completed when the guarantee is cleared. | | `notes` | string \| null | no | | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### QuotaPeriod One quota period - a month, quarter or year in the agency's own timezone, auto-created as time passes. Locking a period freezes its figures; later changes to counted placements post as adjustments to the current open period. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `label` | string | no | | | `start_date` | string (date) | no | Inclusive. | | `end_date` | string (date) | no | Inclusive. | | `status` | string (`open`, `published`, `locked`) | no | | | `agency_target` | number \| null | no | The agency-wide target. Null when none is set (targets are never required) AND null for any key whose data scope is not the whole agency - it is an agency-wide disclosure and travels with the agency block. | | `locked_at` | string (date-time) \| null | no | | ### QuotaPlan The agency's quota plan - one per agency, auto-created with defaults on first read (zero setup). Read-only over the API; plan defaults are managed in Settings > Quotas. | Field | Type | Required | Description | | --- | --- | --- | --- | | `cadence` | string (`monthly`, `quarterly`, `yearly`) | no | | | `currency` | string | no | ISO 4217 code targets are measured in. One currency per plan; other currencies are reported as their own lines, never converted. | | `recognition_basis` | string (`offer_accepted`, `start_date`) | no | When a placement counts: when the offer was accepted, or when the candidate starts (default). | | `include_permanent` | boolean | no | | | `include_contract` | boolean | no | Contract placements count their expected GP; perm placements count the fee. | | `extensions_rule` | string (`extension_start`, `not_counted`) | no | | | `quarter_start_month` | integer, 1-12 | no | First month of the cycle. 1 = Jan/Apr/Jul/Oct quarters; 7 = an Australian financial year. | | `lock_closed_periods` | boolean | no | | ### Referee | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `candidate_id` | string | no | | | `name` | string | no | | | `email` | string (email) | no | | | `phone` | string \| null | no | Mobile number. | | `job_title` | string \| null | no | | | `company` | string \| null | no | Where the candidate and referee worked together. | | `relationship` | string \| null | no | | | `worked_from` | string \| null | no | Start of the period they worked together, YYYY-MM. | | `worked_to` | string \| null | no | End of the period they worked together, YYYY-MM. | | `how_worked_together` | string \| null | no | | | `source` | string (`manual`, `resume_parse`, `candidate_submitted`) | no | How the referee was added. | | `submitted_at` | string (date-time) \| null | no | When the candidate submitted this referee via the Request References form. | | `created_at` | string (date-time) | no | | ### ReviewQueueItem | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `application_id` | string \| null | no | | | `review_type` | string (`score_review`, `reference_red_flag`, `chase_escalation`, `hm_no_response`, `withdrawal`, `strong_match`, `candidate_stalled`, `scorecard_no_decision`, `candidate_replied`, `interview_invite_no_response`, `interview_booked`, `reference_returned`, `referee_details_returned`, `guarantee_ending`, `onboarding_completed`, `submission_no_response`, `placement_starting`, `rejection_due`, `cross_match`, `spec_match`, `client_interview_requested`, `client_passed`, `offer_no_placement`, `interview_confirmed`, `interview_rescheduled`, `interview_cancelled`, `interview_outcome_due`, `interview_no_show`) | no | | | `priority` | string (`urgent`, `high`, `normal`, `low`) | no | | | `title` | string | no | | | `description` | string \| null | no | | | `action_required` | string \| null | no | | | `resolved` | boolean | no | | | `resolved_at` | string (date-time) \| null | no | | | `snoozed_until` | string (date-time) \| null | no | If set to a future time, the item is hidden from the default GET listing until this moment passes. | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | 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 | | ### Spec A spec (Float in AU/NZ): a speculative submission - one FULLY ANONYMOUS candidate profile sent to one client contact with no job, with the agency's terms of business attached as a PDF. The contact opens a no-login profile page and responds one-click (interested / question / not right now). Interest converts to a real job + application via the convert_spec batch op. The candidate's name and current employer are scrubbed server-side from every client-facing field. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `candidate_id` | string | no | | | `client_id` | string | no | | | `client_contact_id` | string | no | | | `status` | string (`sent`, `viewed`, `interested`, `passed`, `converted`, `expired`, `revoked`) | no | | | `anonymous_profile` | object | no | The client-facing profile, exactly as approved by the consultant (and scrubbed of identifying details). | | `anonymous_profile.headline` | string | no | | | `anonymous_profile.chips` | array of string | no | | | `anonymous_profile.pitch` | string | no | | | `anonymous_profile.why_client` | string \| null | no | | | `email_subject` | string | no | | | `terms_attached` | boolean | no | Whether the agency terms of business PDF rode along - the introduction record. | | `candidate_aware` | boolean | no | The honest record: does the candidate know they were specced? | | `url` | string | no | The client-facing profile link. No login - anyone with the link can respond. Expires with the spec (30 days). | | `sent_at` | string (date-time) | no | | | `expires_at` | string (date-time) | no | | | `first_viewed_at` | string (date-time) \| null | no | | | `responded_at` | string (date-time) \| null | no | | | `response` | string (`interested`, `passed`, null) \| null | no | | | `response_note` | string \| null | no | | | `response_rating` | integer, 1-5 \| null | no | The client's 1-5 rating, captured when they mark interest. Null if not rated. | | `converted_job_id` | string \| null | no | | | `converted_application_id` | string \| null | no | | | `created_at` | string (date-time) | no | | ### SsoConnection Single sign-on connection for an account. Each connection is bound to a single email domain; users signing in with that domain are routed through the associated identity provider. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `provider` | string (`google`, `microsoft`, `saml`) | no | google = Google Workspace, microsoft = Microsoft Entra / Azure AD, saml = any generic SAML 2.0 IdP (Okta, JumpCloud, OneLogin, etc). | | `domain` | string | no | | | `status` | string (`pending`, `active`, `disabled`) | no | | | `workos_organization_id` | string \| null | no | WorkOS organization ID. Used to generate Admin Portal setup URLs. | | `created_at` | string (date-time) | no | | | `last_verified_at` | string (date-time) \| null | no | | ### Submission A shortlist of candidates on a job, sent to the job's client contact as a tokenised no-login link. The contact requests interviews, passes with a reason, or asks questions; every response updates the pipeline and notifies the consultant. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `job_id` | string | no | | | `job_title` | string \| null | no | The job's title, embedded so list consumers never need a lookup per row. | | `client_id` | string | no | | | `client_name` | string \| null | no | The client's name, embedded so list consumers never need a lookup per row. | | `client_contact_id` | string | no | | | `status` | string (`pending`, `viewed`, `partially_responded`, `completed`, `expired`, `revoked`) | no | | | `title` | string \| null | no | | | `email_subject` | string \| null | no | The subject line the client contact received. Null on submissions sent before subjects were editable (2026-08-16); those fall back to the generated default when resent. | | `greeting` | string \| null | no | The opening line the client contact received, no trailing comma (e.g. "Hi Brett"). Null on submissions sent before the greeting was stored (2026-08-16); those fall back to "Hi {first name}" when resent. | | `intro_body` | string \| null | no | What the consultant wrote above the shortlist, in their own words, shown in the email and on the review page. Null means the generated default ("{consultant} at {agency} put this shortlist together for you..."). | | `message` | string \| null | no | Legacy covering note, kept on submissions sent before intro_body existed (2026-08-16). It renders under the default intro. Write intro_body instead. | | `identity_visibility` | string (`full`, `first_name`, `anonymous`) | no | How much of each candidate the client sees on the review link. full: name, LinkedIn, employers and schools. first_name: first name only, no surname and no LinkedIn. anonymous: no name (each candidate is "Candidate A", "Candidate B"), no LinkedIn, and employers and schools withheld. Redaction is applied server-side before the payload leaves, and to the email as well as the page. | | `url` | string | no | The client-facing review link. No login - anyone with the link can respond. Expires with the submission. | | `expires_at` | string (date-time) | no | 7 days from send; resending extends it. | | `first_viewed_at` | string (date-time) \| null | no | | | `completed_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | no | | | `items` | array of SubmissionItem | no | | ### TalentPool | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `name` | string | no | | | `slug` | string | no | | | `eligibility` | string \| null | no | Plain-English description of who belongs. When auto_add_enabled is true this is the AI matcher input - judged semantically (related skills, adjacent titles, locations), not by keywords. | | `auto_add_enabled` | boolean | no | When true, every new candidate is AI-matched against eligibility when their profile lands in the bank (and again on assessment), auto-adding on a match. Pools with a score floor only match assessed candidates. | | `min_job_fit` | integer, 0-100 \| null | no | Optional AI assessment score floor gating the match. | | `min_hm_fit` | integer, 0-100 \| null | no | Optional Client Fit score floor gating the match (the field name is historical). | | `description` | string \| null | no | | | `who_we_hire` | string \| null | no | | | `video_url` | string \| null | no | | | `is_published` | boolean | no | | | `keep_warm_enabled` | boolean | no | When true, members get a monthly check-in email asking whether they are still looking (one-click answers: interested, tempted with a note, or not interested). Members who answer not interested are never emailed again. | | `cover_color` | string (`cyan`, `green`, `lime`, `amber`, `orange`, null) \| null | no | Chosen cover-tile colour for the dashboard. Null falls back to a colour derived from the pool name. | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | | ### Task | Field | Type | Required | Description | | --- | --- | --- | --- | | `task_id` | string | no | | | `status` | string (`queued`, `processing`, `completed`, `failed`) | no | | | `resource_id` | string \| null | no | | | `estimated_seconds` | integer | no | | | `poll_url` | string | no | | ### Team | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `name` | string | no | | | `parent_team_id` | string \| null | no | Parent team this team nests under in the org chart, or null at top level. | | `created_at` | string (date-time) | no | | ### User | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `account_id` | string | no | | | `name` | string | no | | | `first_name` | string \| null | no | | | `last_name` | string \| null | no | | | `email` | string (email) | no | | | `role` | string (`admin`, `team_leader`, `recruiter`) | no | | | `team_ids` | array of string | no | The team the accepted member sits on. Always [] while their invite is pending. | | `status` | string | no | | | `slack_user_id` | string \| null | no | | | `whatsapp_number` | string \| null | no | | | `phone` | string \| null | no | | | `linkedin_url` | string \| null | no | | | `created_at` | string (date-time) | no | | ### Webhook | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `url` | string (uri) | no | | | `events` | array of string | no | | | `status` | string (`active`, `paused`) | no | | | `created_at` | string (date-time) | no | | ### WebhookDelivery | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | no | | | `webhook_id` | string | no | | | `event` | string | no | | | `status` | string | no | | | `attempts` | integer | no | | | `last_response_code` | integer \| null | no | | | `last_error` | string \| null | no | | | `next_retry_at` | string (date-time) \| null | no | | | `payload` | object | no | | | `created_at` | string (date-time) | no | | ### WorkflowRules Per-company configuration for how Lovelio scores, triages, and follows up on applications. | Field | Type | Required | Description | | --- | --- | --- | --- | | `ai_assessment_job_fit_advance` | integer, 0-100 | no | Job Fit score at or above which a new application receives the AI verdict "yes" (UI label: Yes). Default 75. Both the Job Fit and Client Fit advance bars must be cleared for a verdict of yes. The AI never moves state - humans action verdicts from the inbox. | | `ai_assessment_job_fit_reject` | integer, 0-100 | no | Job Fit score at or below which a new application receives the AI verdict "no" (default 50). Lovelio does not auto-reject; a human must confirm. | | `ai_assessment_hm_fit_advance` | integer, 0-100 | no | Client Fit score (alignment with the client's DNA; the field name is historical) at or above which a new application receives the AI verdict "yes". Default 60. Both the Job Fit and Client Fit advance bars must be cleared for a verdict of yes. | | `ai_assessment_hm_fit_reject` | integer, 0-100 | no | Client Fit score at or below which a new application receives the AI verdict "no" (default 50). | | `screen_advance_threshold` | integer, 0-100 | no | Phone screen score at or above which Lovelio recommends submitting the candidate to the client. Recommendations never move a candidate automatically. | | `screen_reject_threshold` | integer, 0-100 | no | Phone screen score at or below which Lovelio recommends parking the candidate. | | `submission_chase_days` | integer, 1-14 | no | Days of client silence on a submission before the consultant gets a follow-up prompt. The follow-up email is always reviewed and sent by a person, never automatically. | | `hm_interview_feedback_deadline_hours` | integer, 1-720 | no | Hours after a scheduled interview before the interviewer gets a feedback nudge. | | `referee_details_chase_days` | integer, 0-60 | no | | | `referee_details_escalate_days` | integer, 0-60 | no | | | `referee_form_chase_days` | integer, 1-14 | no | Days between follow-up emails to referees who have not completed their reference. Two follow-ups, then the referee is flagged unresponsive. | | `default_expected_reference_count` | integer, 1-5 | no | Default number of referees requested per candidate. Adjustable per request. | | `hot_candidate_job_fit_threshold` | integer, 50-100 | no | Job-fit score at or above which a candidate qualifies as hot. Combined with company-fit threshold via AND. Default 80. | | `hot_candidate_company_fit_threshold` | integer, 50-100 | no | Company-fit score at or above which a candidate qualifies as hot. Combined with job-fit threshold via AND. Default 80. | | `auto_reject_no_enabled` | boolean | no | When true (default), applications sitting in the "no" stage past auto_reject_no_days surface a rejection prompt on the dashboard. The drafted rejection email is always reviewed and sent by a person, never automatically. | | `auto_reject_no_days` | integer, 1-14 | no | Days an application sits in the "no" stage before the rejection prompt appears. Default 3, so a candidate is never rejected the same day they apply. | | `created_at` | string (date-time) | no | | | `updated_at` | string (date-time) | no | |