{"openapi":"3.1.0","info":{"title":"Lovelio API","version":"7.2.0","description":"Recruitment automation API. Create jobs, manage candidates, run ad-hoc actions, integrate with your tools.\n\n**Breaking change in 2.0.0 (2026-05-18):** the 11-stage pipeline collapsed to a 6-state triage model. The `stage` field now takes one of: maybe (default), yes, no, rejected, hired, withdrew. AI verdict (`ai_verdict`) emits the same in-play vocabulary: yes, maybe, no. Old values (new, longlisted, shortlisted, interviews, checks, offer, hired_onboarding, hired_onboarded, to_be_rejected, advance, consider, reject) are no longer accepted; existing rows were migrated: longlisted/shortlisted/interviews/checks/offer -> yes, new -> maybe, to_be_rejected -> no, hired_onboarding/hired_onboarded -> hired. There is no transition graph - any state can move to any other state. Actions (phone screen, interview, references, offer) are decoupled from state; run them on any yes application.\n\nTo reject: POST /applications/{id}/reject. To withdraw: POST /applications/{id}/withdraw. To change state: POST /applications/{id}/stage.\n\n**2.1.0 (2026-06-12):** GET /stages returns the 6-state model (the old transition-graph response is gone). New batch ops: create_talent_pool, remove_from_talent_pool. POST /accounts/verify-email documented. The dead /form-instances/{id}/submit entry was removed - form submission happens via the candidate-facing magic-link flow, not the V1 API.\n\n**2.5.0 (2026-06-19):** Talent pools gained an optional `cover_color` (one of cyan, green, lime, amber, orange, or null). It sets the dashboard cover-tile colour; null falls back to a name-derived colour. Returned by GET, settable on POST and PATCH.\n\n**2.8.0 (2026-06-24):** Two-tier application source. `source` on the Application object is now a canonical category (Careers Page, Job Board, Referral, Talent Pool, Database Search, Social, Agency, Direct, Imported, Other) and a new `source_detail` field carries the granular sub-source (exact board, referrer, pool, or search). POST /applications accepts either a `source` slug (mapped to a category + detail) or the structured `source_category` + `source_detail` pair. The old free-form source enum (LinkedIn, Indeed, Job Ad, ...) is gone; those values now map to a category with the old value preserved as detail. Filtering GET /applications by `source` filters on the category.\n\n**Breaking change in 3.0.0 (2026-07-02):** the 6-state triage model became the 9-stage agency model. The `stage` field now takes one of: maybe (default), yes, no, submitted, client_interview, offer, placed, rejected, withdrew. `hired` was renamed to `placed`; existing rows were migrated. Three client-side stages were added: submitted (sent to the client), client_interview, and offer. Actions drive stages: creating an offer moves the application to offer, an accepted offer moves it to placed. Job stats objects now include one count per stage and the `hired` count was renamed `placed`. Clients arrived: a client is a company the agency recruits for, a record inside the tenant. New batch ops create_client, update_client, delete_client, add_client_contact, update_client_contact, delete_client_contact under the new clients:write scope.\n\n**3.1.0 (2026-07-02):** Jobs belong to clients. The Job object gains `client` and `client_contact`; POST/PATCH /jobs accept `client_id` (cli_) and `client_contact_id` (cct_, must belong to the client); GET /jobs filters by `client_id`. Full REST surface for clients: GET/POST /clients, GET/PATCH/DELETE /clients/{id}, GET/POST /clients/{id}/contacts, PATCH/DELETE /clients/{id}/contacts/{contactId} under clients:read / clients:write. Creating a client with a website triggers enrichment and an automatic Client DNA first pass; assessments for that client's jobs score against it.\n\n**3.2.0 (2026-07-02):** Submissions - the core agency loop. A submission is a shortlist of candidates on a job, sent to the job's client contact as a tokenised no-login link; the client requests interviews, passes with a reason, or asks questions, and every response updates the pipeline. New endpoints: GET/POST /submissions, GET /submissions/{id} under the new submissions:read / submissions:write scopes. Sending moves triage-stage candidates to submitted automatically. New batch ops create_submission and answer_submission_question. Interviews gained a `flavour` (internal, the agency's own screen, or client) and a nullable `submission_id` linking a client interview to the submission it came from; booking a client interview moves the application to client_interview. New webhook events: submission.created, submission.responded.\n\n**Breaking change in 3.3.0 (2026-07-02):** Placements replaced Offers. In agency perm recruitment the client makes and signs the offer with the candidate, so the offer-letter machinery (offer letters, e-signature portal, HM approval) is retired: every /offers* endpoint is gone, along with the Offer schema, the offers:read / offers:write scopes, the offer.* webhook events, and the offer_created / offer_updated activity types. In their place: a Placement is the record of the win - candidate, client, job, salary, fee (percent of first-year salary or fixed; fee.amount is the computed billing figure), start_date, and a guarantee lifecycle (pending_start, started, fell_off, completed). New endpoints GET/POST /placements and GET/PATCH /placements/{id} under placements:read / placements:write. New batch ops log_offer (marks the client's offer - moves the application to the offer stage, applications:write), create_placement, update_placement, mark_placement_status. New webhook events placement.created and placement.status_changed. Logging a placement against an application moves it to placed automatically.\n\n**3.4.0 (2026-07-02):** Teams are desks of consultants. Team membership moved off the retired employee spine onto the member record, so `team_ids` on a User now reflects the accepted member's team and is always [] for a user whose invite is pending. POST /accounts/{id}/users no longer accepts `team_id` (it returns a validation error): assign a team after the user accepts their invite, via PATCH /accounts/{id}/users/{userId} with `team_id` (send null to clear).\n\n**3.5.0 (2026-07-03):** AI voice screen. New batch op send_ai_voice_screen (applications:write): emails the candidate a tokenised no-login link to a ~10 minute AI voice screening conversation in the browser. When the conversation ends the transcript is scored per question and the application gains a phone_screen_score (0-100). Re-sending while a link is still open re-uses the existing link.\n\n**3.6.0 (2026-07-03):** Database matching + per-job AI screening. New batch op match_candidates_to_job (candidates:read): ranks the whole candidate database against a job (semantic match + AI re-rank) and returns up to 25 matches with a 0-100 score and a one-line rationale each - read-only, attach a match with create_application (pass search_query so the source is credited as Database Search). New batch op set_ai_screening (jobs:write): turns automatic AI voice screening on or off for a job and sets the screen's focus areas. When on, every inbound applicant whose AI verdict lands yes or maybe is invited to the voice screen automatically; candidates a consultant adds by hand are never auto-invited. The Job object gains ai_screen_enabled (boolean) and ai_screen_focus (array of strings, nullable).\n\n**Breaking change in 3.7.0 (2026-07-04):** GET /jobs/{id}/weekly-digest is gone. The Monday-morning weekly job digest was retired along with the endpoint that mirrored it. For pipeline numbers, use GET /jobs/{id} stats or GET /analytics/funnel.\n\n**3.8.0 (2026-07-04):** Spec pitching. New batch op send_spec_pitch (clients:write): sends the consultant-approved speculative (MPC) pitch email behind a spec_match talent-radar card - a hot candidate whose profile echoes a client's Client DNA, pitched to the client contact with no live role. Payload { review_item_id, client_contact_id?, email_subject, email_body }; the card resolves on send. Nothing is ever sent without a person approving the copy.\n\n**3.9.0 (2026-07-04):** Video calls. New batch op start_video_call (interviews:write): creates a Lovelio video call room on an interview and emails the candidate a tokenised no-login join link (pass skip_email: true to get the link without emailing). The call is recorded (audio only) and transcribed live with per-speaker attribution; the recording and transcript land on the interview. Re-running while a link is still open re-uses it. Returns { room_id, candidate_join_url, expires_at, email_sent, reused }. Participants give explicit recording consent on the join screen before any device access.\n\n**3.10.0 (2026-07-04):** Business development leads. Lovelio's BD radar scans live job ads daily and surfaces a handful of targeting briefs per agency: companies that look like your best clients, hiring right now, with your own placement history as the proof. Briefs only - Lovelio never drafts or sends outreach; humans own the BD. Two new batch ops (clients:write): dismiss_bd_lead { lead_id } (forever - a dismissed employer is never suggested again) and convert_bd_lead { lead_id, website? } (creates the client record; enrichment and Client DNA kick off automatically).\n\n**3.11.0 (2026-07-05):** BD territory map. The BD radar now keeps a standing map of the agency's patch: every direct employer the market observatory has seen recently, scored 0-100 against the agency's own history (placements, jobs worked, salary bands - deterministic, nothing invented). Targets carry a right-to-win line and ad evidence; the human owns the status. Three new batch ops (clients:write): pursue_bd_target { target_id, stop? } (pin a target you are actively working; stop: true unpins), dismiss_bd_target { target_id } (forever - the target leaves the map and the employer is never suggested as a lead again) and convert_bd_target { target_id, website? } (creates the client record; enrichment and Client DNA kick off automatically).\n\n**3.12.0 (2026-07-05):** BD reason-to-call. Territory targets accumulate a dated intelligence feed (market signals like a hiring spike or hiring resuming, candidate-corpus signals, and consultant-logged touches). New batch op log_bd_target_touch { target_id, note? } (clients:write): records that the CONSULTANT reached out (call, coffee, their own email) and resets the cadence clock. Lovelio never drafts or sends outreach - the log is the only automation.\n\n**3.13.0 (2026-07-06):** BD patch. The agency describes what it recruits in its own words (roles, regions, seniority, anything it never touches) and that statement becomes the declared half of the BD engine: declared desks count as lookalike fit before any placement exists, and exclusions remove targets and leads outright. New batch op update_bd_patch (clients:write), two-phase: call with { statement } to get data.preview (the AI's structured reading and a playback line, no write), then { apply: true, statement, profile: preview.profile } to save. Saving triggers an immediate territory re-score.\n\n**3.14.0 (2026-07-06):** Spec correctness pass. The webhook event catalogue is now the real, emitted set (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, *) - the earlier list named events that were never fired (application.created/updated, candidate.created/updated, interview.completed, job.updated/closed/daily_summary_ready, form_instance.completed, review_queue.created). POST /webhooks and PATCH /webhooks/{id} both validate against this set. Method corrections: POST (not PATCH) /applications/bulk-update; POST /health is documented (webhook test target); the phantom PATCH /job-ads/{id} (edit ad copy via PATCH /jobs/{id}/ad) and GET /accounts/{id}/api-keys/{keyId} entries were removed. GET /applications and GET /candidates now return cursor pagination meta and honour the `after` cursor (they previously ignored it).\n\n**3.15.0 (2026-07-06):** The BD engine is queryable. New read endpoints: GET /bd/targets (territory map; filters status, market, min_score), GET /bd/targets/{id} (includes the reason-to-call event feed), GET /bd/leads (+ /{id}), GET /bd/patch (declared patch + territory state). Targets carry bdt_ ids and leads bdl_ ids, and the BD batch ops (pursue_bd_target, dismiss_bd_target, convert_bd_target, log_bd_target_touch, dismiss_bd_lead, convert_bd_lead) accept them alongside raw ids. Writes stay batch ops - one write path per action, every surface. Also: GET /webhooks/events returns the machine-readable event catalogue (every event listed is really emitted); created_after / created_before ISO date filters on GET /jobs, /candidates, /applications, /placements, /interviews (a malformed value is a 422, never ignored); GET /activities now returns cursor pagination meta and honours the standard base64 after cursor (a raw ISO timestamp still works); key-type semantics documented (sk_live_ production, sk_test_ development against the same workspace data, sk_trial_ expires 7 days after signup). The candidate_hired Slack notification type was removed - nothing ever emitted it; placement_created covers the win.\n\n**3.16.0 (2026-07-08):** Specs (Floats in AU/NZ) - the speculative submission: one FULLY ANONYMOUS candidate profile sent to one client contact with no job, the agency's terms of business attached as a PDF, and a no-login profile page where the contact responds one-click. New endpoints: GET/POST /specs, GET /specs/{id} (spc_ ids) under the submissions scopes. The candidate's name and current employer are scrubbed server-side from every client-facing field; a candidate can never be specced to their own employer. New batch ops: send_spec (submissions:write), chase_spec (the consultant-approved nudge on a quiet spec), convert_spec (jobs:write - interest becomes a real job + application at the submitted stage via the standard creation paths). New webhook events: spec.sent, spec.viewed, spec.responded, spec.converted. A placement of the candidate at the client within 12 months carries the introduction record back to the spec. The send_spec_pitch batch op is retired, absorbed by send_spec.\n\n**3.17.0 (2026-07-10):** Spec (Float) client feedback reaches parity with submissions. The Spec object gains response_rating (the client's 1-5 rating, captured with their interest; null if not rated). Client questions on a spec are now a persisted Q&A thread rather than a one-shot notification: new batch op answer_spec_question (submissions:write) posts the consultant's reply and emails the contact the answer is waiting, mirroring answer_submission_question.\n\n**3.18.0 (2026-07-14):** The cross-agency split marketplace reaches the API (Premium plans). New endpoints under /marketplace: browse and share listings (GET/POST /marketplace/listings, GET/PATCH /marketplace/listings/{id}), make and answer intros (GET/POST /marketplace/intros, GET /marketplace/intros/{id}, POST /marketplace/intros/{id}/respond with check / accept / decline / withdraw), and read deals with the paid/received handshake (GET /marketplace/deals, GET/PATCH /marketplace/deals/{id}). New scopes marketplace:read and marketplace:write; the eight marketplace batch ops moved from the borrowed jobs/candidates/submissions/placements scopes to marketplace:write. Three new webhook events: marketplace.intro.received, marketplace.deal.formed, marketplace.deal.placed (deal events fire for both agencies). External ids: mkl_ (listing), mki_ (intro), mkd_ (deal). The posture rides the API unchanged: reveal-on-accept, pre-scrubbed snapshots only, Lovelio takes 0% and never touches the money.\n\n**3.18.1 (2026-07-14):** Marketplace hardening. PATCH /marketplace/deals/{id} gains action add_to_pipeline (with job_id): the agency the candidate was revealed to turns them into a real candidate + application on one of its jobs and links the deal, so deal status tracks the pipeline on candidate-listing deals too. The prior_knowledge object returned by POST /marketplace/intros/{id}/respond is now fully documented and snake_case ({ known, matched_by, added_at, matched_candidate_id, note }); it is null on request intros pre-accept, where the verdict belongs to the requesting agency. Accepting an intro on a withdrawn, filled or expired listing is now refused, and withdrawing or filling a listing resolves its pending intros immediately (senders are notified with the real reason). Listing text fields are hard-capped (headline 200, chips 60 each, pitch 4,000, note 2,000 characters).\n\n**3.18.2 (2026-07-14):** Marketplace deal lifecycle and API standards. PATCH /marketplace/deals/{id} gains action close (with a required reason): either side ends an unplaced deal, the reason goes to the other agency, and a new webhook event marketplace.deal.closed fires for both sides; placed deals never close this way. The MarketplaceDeal object gains placed_currency (the placement's real currency - placed money renders in this, while currency stays the listing's asking figure) and closed_at / closed_by / closed_reason. mark_paid and mark_received are now idempotent: the first stamp is the record, repeats are no-ops that never overwrite the timestamp or re-notify. Status codes joined the platform standard: the marketplace premium gate returns 402 with a pricing link (was 403), responding to an already-answered intro returns 409 (was 422), and a tenant that is not a party to an intro gets the same 404 as a missing id (no existence probe).\n\n**Breaking change in 3.19.0 (2026-07-14):** Job matching runs on the faceted search core - one engine for every candidate-finding surface. The match_candidates_to_job batch op's result changed shape: each match now carries a deterministic `band` (strong / good / partial), `coverage` ({ matched, total } requirements), per-requirement `evidence` rows (each with label, matched, source, detail, and an inferred flag - derived from the data, never generated), and a `narration` sentence written after ranking. The 0-100 `score`, `rationale` and `location_mismatch` fields are gone (a location mismatch now shows as an unmatched location evidence row). The result also returns the job's compiled requirement `facets`, the honest `total_count` of candidates matching every hard requirement, and a `search_log_id` - pass it back as `search_log_id` on create_application so placements attribute to the match run. New optional payload field `facet_state` re-runs the match with edited requirements.\n\n**3.20.0 (2026-07-14):** Pay and availability capture. The Candidate object gains salary_expectation (raw, verbatim), salary_expectation_annual + salary_currency (derived), notice_period + notice_period_days (derived; 0 = immediately available), work_rights + work_rights_status (derived: citizen, permanent_resident, visa_holder, requires_sponsorship), and years_experience now prefers the employment-ledger rollup (overlap-merged from dated employment) over the raw work-history sum. PATCH /candidates/{id} accepts the three raw fields; the normalizations derive automatically and cannot be set directly. Sources that fill these: CV parse (explicit statements only), interview call insights (the freshest, overwrites), and edits. Null always means never captured, not zero.\n\n**3.21.0 (2026-07-16):** Candidate preferences. The Candidate object gains preferred_roles, preferred_seniority (intern..c_suite), work_types (permanent, contract, temp, part_time), open_to_relocation, available_from (date), preferred_locations (gazetteer-resolved for search), plus salary_expectation_amount + salary_expectation_period derived from salary_expectation (day and hour rates are first-class; an annual figure is never fabricated from a rate). POST /candidates and PATCH /candidates/{id} accept all of them; preferred_locations replaces the full set on every write. New batch actions: update_candidate_preferences and extract_candidate_preferences (reads the candidate's notes and proposes a patch; apply: true writes the confirmed proposal). Null always means never captured.\n\n**3.22.0 (2026-07-16):** Tags. Flat labels on candidate records - no colours, no categories, just names. The Candidate object's tags field is now real: it has existed since 1.0 and returned an empty array for every candidate ever, and it now returns the labels on the record, alphabetically. POST /candidates accepts tags, and PATCH /candidates/{id} replaces the full set (an empty array clears them, omitting the field leaves them alone). Labels are deduplicated case-insensitively per account, so \"Python\" and \"python\" are one tag and it comes back with the spelling it was first created with; max 20 tags per candidate, 40 characters each. New batch action: update_candidate_tags, which takes either tags (replace the list) or add / remove (change it without knowing the rest). Search understands tags: \"consultants tagged ex-McKinsey\".\n\n**3.23.0 (2026-07-19):** The Marketplace became an opt-in module, off by default. Every /marketplace route now answers 403 MARKETPLACE_DISABLED until an agency admin turns the module on in Settings > Integrations (the Premium requirement is unchanged: 402 PREMIUM_REQUIRED still comes first). Agencies that already had a listing, intro, or deal were switched on automatically, so no active trade lost access.\n\n**Breaking change in 4.0.0 (2026-07-19):** Client terms became the one source of truth for fees. The four fee-variation batch actions (request_fee_variation, approve_fee_variation, decline_fee_variation, withdraw_fee_variation) were removed along with the whole approval workflow; the single replacement is set_client_fee_terms (clients:write), which sets a client's terms directly - { client_id, terms } or { client_id, revert: true } for back-to-standard - and is admin-only by default. Jobs now inherit the client's live terms: a job with no fee of its own prices off the client schedule at read time, so editing client terms flows to every open job instantly; only the admin-only per-job override writes a job's fee fields. The fee floor (floor_percent) and the below-floor flag are gone with the approvals. Fee schedule objects no longer carry floor_percent or source_request_id.\n\n**4.1.0 (2026-07-19):** Describe-a-job confirm loosened: the client is now the ONLY mandatory gap. POST /jobs/from-description/{review_token}/confirm no longer answers 409 draft_has_gaps for missing team, location, compensation, or employment type - those fields are optional and editable on the created job. A draft without a client still refuses, and skip_job_draft_gap still cannot clear it.\n\n**Breaking change in 5.0.0 (2026-07-19):** The custom automations rule engine retired (settings diet: its useful cases are covered by the curated Email Automations catalog and Workflow rules, and no tenant had ever created a rule). Removed: every /automations endpoint, the Automation schema, the automations:write scope, and the four batch actions create_automation, update_automation, toggle_automation, delete_automation. The custom_automation_rules email opt-in disappeared from the automations catalog with it.\n\n**5.0.1 (2026-07-21):** POST /jobs spec corrected to match what the endpoint actually accepts (no behaviour change). Compensation is a nested `compensation` object ({ base_min, base_max, currency }), not the top-level salary_min / salary_max the spec used to show - those were silently ignored, creating jobs with no salary. `client_id` is now documented as required (it always was: omitting it returns 422). Added `employment_type` (full_time / part_time / contract) and `location.state`, both already honoured. The success-in-90-days field is named `success_in_90_days`; the spec's old `ninety_day_goal` was never read.\n\n**5.1.0 (2026-08-05):** Contacts are relationships to a person. Contact objects gain `person_id` (cnd_, nullable): the person behind the contact, the same id GET /candidates serves them under. A contact's `name` lives on that person record and is shared across every role they hold - PATCHing name on /clients/{id}/contacts/{contactId} renames the person everywhere they appear. POST /clients/{id}/contacts resolves the person first (a matching email, LinkedIn URL or phone links to an existing person); a person already on that client's contact list is returned as-is rather than duplicated (still 201). GET /candidates excludes people who exist only as client contacts - they join the candidate pool when they enter a process.\n\n**5.2.0 (2026-08-05):** Contacts enter the searchable pool. GET /candidates now includes people who exist only as client contacts - a hiring manager is a future candidate. Candidate objects gain `contact_only` (boolean): true while the person exists purely as a contact, flipping false the moment they enter a candidate process. New `contact_only` query param filters either way; omit it to list everyone. POST /candidates/search (AI search) includes contact people by default too - narrow with a person_role facet ('candidates only' / 'contacts only') in the query.\n\n**5.3.0 (2026-08-09):** The BD patch drafts itself. An agency that has never described its patch no longer starts from nothing: update_bd_patch (clients:write) takes a third payload, { draft: true }, which reads the enrichment already in the account (the deep company-intelligence tier first, the signup website scrape as the fallback) and returns data.draft { profile, territory, evidence } - role and sector chips to remove, plus city / region / country territory options to pick one of. Removing a SECTOR chip states an exclusion, which is how exclusions get stated at all (no agency publishes what it will not do) and removes those employers from the map; removing a ROLE chip only leaves it undeclared, so the boards are no longer swept for it and it no longer counts as fit, without blocking any employer. Like the { statement } preview, the draft writes nothing; saving is still { apply: true, statement, profile }. data.draft is null when the account holds too little to draft from.\n\n**Breaking change in 6.0.0 (2026-08-10):** Quotas are readable. Three new endpoints under the new quotas:read scope: GET /quotas/plan (the agency's quota plan - cadence, currency, recognition basis, what counts), GET /quotas/periods (periods newest first; they auto-roll, so the current one always exists), and GET /quotas/periods/{id} where id is a qpd_ id or the literal `current` (the period's board - recognised credit-share money, placement counts and targets for the agency, each team and each consultant, plus the adjustments ledger). Figures come from the quota recognition engine, so recognition basis, credit splits, adjustments and locked-period snapshots are all respected and the API always agrees with the product. Money is a list of one line per currency and is never converted. The board is pruned to the key's data scope: a key that cannot see the whole agency gets its own teams and people, and neither the agency block nor agency_target. BREAKING: the batch actions ask_analytics and run_report now require analytics:read instead of clients:read - they answer across the whole book, including per-consultant money, so clients:read was too weak a gate. Re-issue any key that used those two actions with analytics:read. For Lovelio Connect apps, quotas:read is an elevated-risk scope (it reads what people earn and are measured against), so an app requesting it gets the elevated review tier.\n\n**6.1.0 (2026-08-11):** The Lovelio Connect connect flow is declared in the spec instead of described in prose. A second security scheme, appOAuth2, carries the authorization and token URLs, the PKCE requirement (S256 only), and every scope a partner app may request with its plain-English meaning - so a generated client can drive the connection rather than reading it out of a paragraph. Nothing about the flow changed; it was already served at /api/oauth/authorize and /api/oauth/token. The URLs are the region's own, because an OAuth grant is regional tenant data, the same reason each region is its own server entry. Both the spec and the developer portal's runnable code samples now read one module, lib/connect/connectFlow.ts, so neither can describe a flow the server does not serve.\n\n**6.2.0 (2026-08-14):** A job records how its salary is quoted. The Job object's `compensation` gains `period` (`year`, `day` or `hour`), and both POST /jobs and PATCH /jobs/{id} accept it. Omit it on create and the work type decides: `employment_type: contract` gives `hour`, anything else `year` - the same rule the product uses, so a contract role's rate is no longer created, stored and advertised as an annual salary. `day` is never inferred; send it to quote a day rate. PATCH never infers the period at all: changing employment_type does not re-label figures already on the job. Jobs created before this release hold no period and report `year`, which is how they have always displayed.\n\n**Breaking change in 7.0.0 (2026-08-14):** the 9-stage agency model became the 12-stage model. Three stages joined the `stage` vocabulary: `funnel` (the landing bucket - every new application now starts here instead of `maybe`, and GET /stages reports it as the default), `screen` (the consultant's phone screen, between `yes` and `submitted`), and `references` (the reference check, between `client_interview` and `offer`). `maybe` now means a human's reviewed-but-undecided mark rather than awaiting triage; existing `maybe` rows were migrated to `funnel`. Every stage enum in the spec, GET /stages, and POST /applications/{id}/stage accept the new values. Clients that validate the stage enum strictly must add the three values; nothing else about the stage contract changed - there is still no transition graph.\n\n**7.1.0 (2026-08-14):** actions now walk candidates through the `screen` and `references` stages automatically. Sending an AI voice screen (send_ai_voice_screen) or completing a phone screen form moves a `yes` application to `screen`; confirming a phone-screen draft with outcome advance or hold does the same, reject parks any pre-client application at `no`, and no_show never moves anyone. Requesting referee details (request_referee_details) from an application at `client_interview` moves it to `references`; requests at any other live stage still send without a stage move, and references completing never moves the stage. Every move fires the usual application.stage_changed webhook. Untriaged candidates (`funnel` / `maybe`) are never moved by a screen send, and the AI screen's score never moves anyone - stage moves stay human-driven.\n\n**7.2.0 (2026-08-16):** booking an internal interview (POST /interviews with flavour internal, the default) now moves a `yes` application to `screen` - the screen stage holds phone screens and internal interviews, and `client_interview` is only ever client-flavour interviews. Triage applications (`funnel` / `maybe` / `no`) are never moved by a booking, and an application already at `screen` or with the client stays put. The move fires the usual application.stage_changed webhook. Client-interview booking is unchanged: `submitted` moves to `client_interview`.","contact":{"email":"api@lovelio.ai","url":"https://lovelio.ai/docs"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://us.lovelio.ai/api/v1","description":"Production (US (N. California))"},{"url":"https://eu.lovelio.ai/api/v1","description":"Production (EU (London))"},{"url":"https://anz.lovelio.ai/api/v1","description":"Production (ANZ (Sydney))"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key, sent as Authorization: Bearer <key>. Three key types: sk_live_ (production), sk_test_ (development - same workspace, same data, marked as a test key; use a separate workspace if you need isolated test data), and sk_trial_ (issued at signup, expires 7 days later - swap to a live key from Settings > API keys). Keys are server-generated, shown once at creation, and scoped per permission (or admin for everything). Lovelio Connect apps authenticate with the OAuth access token from the connect flow instead (Authorization: Bearer lc_at_...) - it carries exactly the scopes the agency approved, enforced on every request. Money is gated at FIELD level, not route level: the three scopes placements:financials:read, clients:financials:read and marketplace:financials:read unlock salary, fee, contract rates, expected GP, commission percents, the client fee schedule and split-fee deal amounts. Without them those fields return null and everything else on the record still comes through - a read never 403s for want of a money scope. admin satisfies all three. Requesting any of them puts a Connect app in the elevated review tier. See /docs/agents/build-an-integration."},"appOAuth2":{"type":"oauth2","description":"How a Lovelio Connect app gets a token for an agency that is not its own. Authorization code with PKCE (S256 only - a plain or absent code_challenge is refused), plus the client secret at the token endpoint. Credentials are issued in the developer portal: a client ID (lc_client_) safe to publish and a client secret (lc_secret_) shown once. The exchange returns an access token (lc_at_, 3600 seconds) and a refresh token (lc_rt_) that rotates on every use. The granted scope can be narrower than the requested scope - read the scope field on the response. The URLs below are this region's; use the domain of the agency's own region. Runnable connection and callback source in five languages is printed in the portal against your own client. See /docs/agents/build-an-integration.","flows":{"authorizationCode":{"authorizationUrl":"https://us.lovelio.ai/api/oauth/authorize","tokenUrl":"https://us.lovelio.ai/api/oauth/token","refreshUrl":"https://us.lovelio.ai/api/oauth/token","scopes":{"jobs:read":"Read jobs","jobs:write":"Write jobs","candidates:read":"Read candidates","candidates:write":"Write candidates","applications:read":"Read applications","applications:write":"Write applications","interviews:read":"Read interviews","interviews:write":"Write interviews","placements:read":"Read placements","placements:write":"Write placements","placements:financials:read":"Read placement fees, salaries and commission splits","quotas:read":"Read quotas","clients:read":"Read clients","clients:write":"Write clients","clients:financials:read":"Read your fee schedule for each client","submissions:read":"Read submissions","submissions:write":"Write submissions","talent_pools:read":"Read talent pools","talent_pools:write":"Write talent pools","documents:read":"Read documents","documents:write":"Write documents","emails:read":"Read emails","emails:write":"Write emails","forms:read":"Read forms","forms:write":"Write forms","activities:read":"Read activities","activities:write":"Write activities","analytics:read":"Read analytics","integrations:read":"Read integrations","integrations:write":"Write integrations","marketplace:read":"Read marketplace activity","marketplace:write":"Write marketplace activity","marketplace:financials:read":"Read split-fee deal money","webhooks:read":"Read webhooks","webhooks:write":"Write webhooks","accounts:read":"Read account details"}}}}},"schemas":{"DistributionBoard":{"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"regions":{"type":"array","items":{"type":"string"}},"industries":{"type":"array","items":{"type":"string"}},"pricing":{"type":"string","enum":["free","paid","mixed"]},"auth_type":{"type":"string","enum":["none","api_key","oauth2","basic"]},"capabilities":{"type":"object","properties":{"edit":{"type":"boolean"},"expire":{"type":"boolean"},"applyBack":{"type":"boolean"},"statusQuery":{"type":"boolean"}}},"monthly_post_limit":{"type":"integer","nullable":true,"description":"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":{"type":"integer","description":"Posts consumed on this board in the current calendar month (company timezone)."},"posts_remaining_this_month":{"type":"integer","nullable":true,"description":"Posts left this month (null = unlimited). Never negative."}}},"DistributionRule":{"type":"object","properties":{"id":{"type":"string","example":"drule_..."},"name":{"type":"string"},"filter":{"type":"object","description":"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.","properties":{"country":{"type":"string","example":"AU"},"category":{"type":"string"},"job_type":{"type":"string","enum":["Permanent","Contract","Temporary"]},"team_id":{"type":"string"}}},"target_board_slugs":{"type":"array","items":{"type":"string"}},"active":{"type":"boolean"},"ai_screen_enabled":{"type":"boolean","description":"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":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Consultant-set focus areas the AI screen digs into. Feeds the per-candidate question generator."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"JobBoardPosting":{"type":"object","properties":{"id":{"type":"string","example":"post_..."},"job_id":{"type":"string","nullable":true},"job_ad_id":{"type":"string","nullable":true},"board_slug":{"type":"string"},"status":{"type":"string","enum":["pending","publishing","live","expired","rejected","error"]},"url":{"type":"string","nullable":true},"external_id":{"type":"string","nullable":true},"error_code":{"type":"string","nullable":true},"error_message":{"type":"string","nullable":true},"published_at":{"type":"string","format":"date-time","nullable":true},"updated_at":{"type":"string","format":"date-time"}}},"Error":{"type":"object","properties":{"success":{"type":"boolean","example":false},"data":{"type":"null"},"meta":{"$ref":"#/components/schemas/Meta"},"error":{"type":"object","properties":{"code":{"type":"string","example":"VALIDATION_ERROR"},"message":{"type":"string","example":"url is required."},"field":{"type":"string","example":"url"},"docs":{"type":"string","format":"uri"}}}}},"Meta":{"type":"object","properties":{"request_id":{"type":"string","example":"req_abc123def456"},"count":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true},"prev_cursor":{"type":"string","nullable":true}}},"CandidateSearchResult":{"type":"object","description":"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.","properties":{"entity_type":{"type":"string","enum":["candidates"],"description":"Present on POST /search responses so a client can branch; absent from POST /candidates/search, which only ever searches candidates."},"interpretation":{"type":"object","description":"How the engine read the query.","properties":{"intent":{"type":"string","nullable":true,"description":"The soft ask used for semantic ranking."},"facets":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","description":"Plain-English label, as shown on the in-app chips."},"kind":{"type":"string","description":"What the facet checks (role, skill, location, employer, ...). \"unsupported\" means the engine understood the ask but cannot filter on it yet."},"strength":{"type":"string","enum":["must","nice","exclude"],"description":"must filters, nice ranks, exclude removes."}}}}}},"total_count":{"type":"integer","description":"Real database count of candidates matching every hard requirement."},"ranked_pool_size":{"type":"integer","description":"How many of those were ranked - the depth pagination can reach."},"page":{"type":"integer"},"page_size":{"type":"integer"},"has_more":{"type":"boolean"},"notes":{"type":"array","description":"Anything the engine could not do properly (a degraded plan, a truncated scan). An empty array means everything ran as interpreted.","items":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}}}},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"External candidate id (cnd_...).","example":"cnd_550e8400e29b41d4a716446655440000"},"name":{"type":"string","nullable":true},"email":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"current_job_title":{"type":"string","nullable":true},"current_employer":{"type":"string","nullable":true},"match_score":{"type":"integer","description":"The engine's deterministic feature score, 0-100. Decides the order."},"band":{"type":"string","enum":["strong","good","partial"],"description":"Requirement-coverage band."},"coverage":{"type":"object","properties":{"matched":{"type":"integer"},"total":{"type":"integer"}},"description":"Requirements this candidate evidences, out of the total asked."}}}},"mentioned_in_cv":{"type":"object","description":"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.","properties":{"total_count":{"type":"integer","description":"Real database count of the whole tier, which can exceed the rows returned."},"facet_labels":{"type":"array","items":{"type":"string"},"description":"Which interpretation facets these people reached by a CV mention rather than by employment history."},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"cnd_550e8400e29b41d4a716446655440000"},"name":{"type":"string","nullable":true},"email":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"current_job_title":{"type":"string","nullable":true},"current_employer":{"type":"string","nullable":true}}}}}}}},"EntitySearchResult":{"type":"object","description":"What POST /search returns for applications, jobs and interviews: structured rows plus a plain-language reading of the query.","properties":{"entity_type":{"type":"string","enum":["applications","jobs","interviews"]},"interpretation":{"type":"string","description":"Human-readable description of the search","example":"Showing active jobs"},"results":{"type":"array","items":{"type":"object"}},"total_count":{"type":"integer"},"page":{"type":"integer"},"page_size":{"type":"integer"},"has_more":{"type":"boolean"},"degraded":{"type":"boolean","description":"True when the AI could not fully interpret the query and fell back to keyword search"},"degraded_reason":{"type":"string","description":"Human-readable explanation of why the search was degraded"},"unsupported_concepts":{"type":"array","items":{"type":"string"},"description":"Concepts in the query that the system does not yet support (e.g. call tracking)"},"suggested_alternatives":{"type":"array","items":{"type":"string"},"description":"Alternative queries the system can answer"}}},"Job":{"type":"object","properties":{"id":{"type":"string","example":"job_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"title":{"type":"string","example":"Senior Software Engineer"},"status":{"type":"string","enum":["active","on_hold","closed","filled"],"description":"Jobs default to active on creation. Move freely between active, on_hold, and closed. filled is set automatically when a hire is accepted."},"client":{"type":"object","nullable":true,"description":"The client this job is for. Every job created in-product gets one; jobs created via this API may omit it.","properties":{"id":{"type":"string","example":"cli_550e8400e29b41d4a716446655440000"},"name":{"type":"string","nullable":true}}},"client_contact":{"type":"object","nullable":true,"description":"The contact at the client for this job. Always belongs to the job's client.","properties":{"id":{"type":"string","example":"cct_550e8400e29b41d4a716446655440000"},"name":{"type":"string","nullable":true}}},"team":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"name":{"type":"string","nullable":true}}},"hiring_manager":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"name":{"type":"string","nullable":true}}},"location":{"type":"object","nullable":true,"description":"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.","properties":{"type":{"type":"string","enum":["on_site","hybrid","remote"]},"city":{"type":"string","nullable":true,"example":"Sydney"},"country":{"type":"string","nullable":true,"example":"Australia"},"country_code":{"type":"string","nullable":true,"description":"ISO 3166-1 alpha-2 country code","example":"AU"}}},"employment_type":{"type":"string","enum":["full_time","part_time","contract"],"nullable":true},"compensation":{"type":"object","properties":{"base_min":{"type":"number","nullable":true},"base_max":{"type":"number","nullable":true},"ote":{"type":"number","nullable":true},"currency":{"type":"string","example":"USD"},"period":{"type":"string","enum":["year","day","hour"],"description":"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":{"type":"string","nullable":true},"spec":{"type":"object","nullable":true,"properties":{"summary":{"type":"string"},"responsibilities":{"type":"array","items":{"type":"string"}},"requirements":{"type":"array","items":{"type":"string"}},"nice_to_have":{"type":"array","items":{"type":"string"}}}},"assessment_criteria":{"type":"array","items":{"type":"object"},"description":"AI-generated criteria for assessing candidates."},"interview_plan":{"type":"object","nullable":true,"description":"AI-generated interview plan."},"job_ad":{"type":"string","nullable":true,"description":"Associated job ad ID."},"share_links":{"type":"object","nullable":true,"description":"Shareable URLs for the job."},"stats":{"type":"object","nullable":true,"description":"Application statistics. List responses carry { total_applications }; GET /jobs/{id} adds one count per pipeline stage."},"target_start_date":{"type":"string","format":"date","nullable":true},"compensation_research":{"type":"object","nullable":true,"description":"Market compensation research data."},"default_interview_format":{"type":"string","enum":["video","phone","in_person","technical"],"default":"video","description":"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":{"type":"boolean","default":false,"description":"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":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Client":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"cli_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"name":{"type":"string","example":"Acme Pty Ltd"},"website":{"type":"string","nullable":true},"domain":{"type":"string","nullable":true,"description":"Normalised bare domain, e.g. \"acme.com\". Derived from website."},"logo_url":{"type":"string","nullable":true},"industry":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"locations":{"type":"array","items":{"type":"string"}},"address":{"type":"string","nullable":true,"description":"One-line postal address, shown on the client's terms document."},"fee_schedule":{"type":"object","description":"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.","properties":{"currency":{"type":"string","example":"AUD"},"fee_basis":{"type":"string","enum":["package","base"],"description":"What the percent applies to: total salary package or base salary."},"tiers":{"type":"array","description":"Slab bands, ascending. Salaries from `from` upward (until the next band) pay `percent` on the whole salary.","items":{"type":"object","properties":{"from":{"type":"number"},"percent":{"type":"number"}}}},"minimum_fee":{"type":"number","nullable":true},"guarantee_days":{"type":"integer","nullable":true},"guarantee_remedy":{"type":"string","enum":["replacement","refund_full","refund_pro_rata","refund_half","replacement_or_refund_half","none"]},"guarantee_notes":{"type":"string","nullable":true},"payment_terms_days":{"type":"integer","nullable":true},"is_standard":{"type":"boolean","description":"True when the client is on the agency standard (no approved variation)."},"version":{"type":"integer","description":"Schedule version - increments on every approved change."}}},"notes":{"type":"string","nullable":true},"enrichment_status":{"type":"string","enum":["none","pending","complete","failed"],"description":"Website enrichment progress. \"none\" for clients created without a website."},"dna_status":{"type":"string","enum":["none","building","complete","failed"],"description":"Client DNA build progress. Assessments for this client's jobs use the DNA once complete."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"ClientContact":{"type":"object","description":"A person at the client: hiring manager, HR, finance. Contacts receive submissions and sit on jobs, always scoped to their client.","properties":{"id":{"type":"string","example":"cct_550e8400e29b41d4a716446655440000"},"client_id":{"type":"string","example":"cli_550e8400e29b41d4a716446655440000"},"person_id":{"type":"string","nullable":true,"example":"cnd_550e8400e29b41d4a716446655440000","description":"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":{"type":"string","description":"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":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"title":{"type":"string","nullable":true},"reports_to":{"type":"string","nullable":true,"example":"cct_550e8400e29b41d4a716446655440000","description":"Another contact (cct_ id) on the same client this person reports to. Builds the client org chart."},"is_primary":{"type":"boolean","description":"One primary per client; setting a new primary demotes the old one."},"origin":{"type":"string","enum":["relationship","works_here"],"description":"\"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":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"CandidateImport":{"type":"object","properties":{"id":{"type":"string","example":"imp_550e8400e29b41d4a716446655440000"},"status":{"type":"string","enum":["uploading","processing","complete","cancelled"]},"file_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time","nullable":true},"completed_at":{"type":"string","format":"date-time","nullable":true},"counts":{"type":"object","properties":{"pending":{"type":"integer"},"processing":{"type":"integer"},"imported":{"type":"integer","description":"New candidates created."},"merged":{"type":"integer","description":"Existing candidates updated (matched by email)."},"failed":{"type":"integer","description":"Files that could not be read."}}},"failures":{"type":"array","items":{"type":"object","properties":{"filename":{"type":"string"},"reason":{"type":"string","description":"Human-readable reason the file was skipped."}}}},"role_matches":{"type":"array","nullable":true,"description":"How the imported candidates compare with the open roles - embedding similarity only, no assessments. Null until matching has run.","items":{"type":"object","properties":{"job_id":{"type":"string"},"job_title":{"type":"string"},"strong_count":{"type":"integer"},"matches":{"type":"array","items":{"type":"object","properties":{"candidate_id":{"type":"string"},"name":{"type":"string"},"score":{"type":"integer","description":"0-100 similarity to the role description."}}}}}}}}},"Candidate":{"type":"object","properties":{"id":{"type":"string","example":"cnd_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"first_name":{"type":"string","example":"Jane"},"last_name":{"type":"string","example":"Smith"},"name":{"type":"string","example":"Jane Smith"},"email":{"type":"string","format":"email","nullable":true},"phone":{"type":"string","nullable":true},"location":{"type":"object","nullable":true,"description":"Where the candidate lives, coarsened to suburb (or city) and state - \"Balmain, NSW\". Never a street address, whatever precision the record was captured at.","properties":{"city":{"type":"string"}}},"linkedin_url":{"type":"string","nullable":true},"current_title":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"integer","nullable":true,"description":"Estimated years of experience from work history."},"skills":{"type":"array","items":{"type":"string"}},"education":{"type":"array","items":{"type":"object","properties":{"degree":{"type":"string","nullable":true},"institution":{"type":"string"},"year":{"type":"integer","nullable":true}}}},"parsed_profile":{"$ref":"#/components/schemas/ParsedProfile"},"cv_url":{"type":"string","nullable":true,"description":"URL to the uploaded CV document."},"status":{"type":"string","example":"active"},"availability":{"type":"string","enum":["unknown","available","passive","not_available"]},"source":{"type":"string","nullable":true,"description":"How the candidate entered the system, e.g. 'bulk_import' for candidates created by a CV import."},"tags":{"type":"array","items":{"type":"string"},"description":"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":{"type":"boolean","description":"Off-limits flag. Flagged candidates are excluded from search, matching and suggestions."},"contact_only":{"type":"boolean","description":"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":{"type":"integer","nullable":true,"minimum":1,"maximum":5,"description":"The agency's 1-5 star rating on the candidate. Null = not yet rated."},"open_to_work":{"type":"boolean","nullable":true,"description":"Open-to-work flag, from a LinkedIn sighting or a consultant's toggle. Null = never captured either way."},"salary_expectation":{"type":"string","nullable":true,"description":"Captured salary expectation, verbatim (\"145k + super\"). Null = never captured."},"salary_expectation_annual":{"type":"number","nullable":true,"description":"Normalized annual figure derived from salary_expectation, when parseable."},"salary_currency":{"type":"string","nullable":true,"description":"Currency code when the expectation stated one (AUD, USD, GBP, EUR, NZD)."},"notice_period":{"type":"string","nullable":true,"description":"Captured notice period, verbatim (\"4 weeks\")."},"notice_period_days":{"type":"integer","nullable":true,"description":"Normalized days derived from notice_period (0 = immediately available)."},"work_rights":{"type":"string","nullable":true,"description":"Captured right-to-work statement, verbatim (\"Australian citizen\")."},"work_rights_status":{"type":"string","nullable":true,"enum":["citizen","permanent_resident","visa_holder","requires_sponsorship"],"description":"Normalized status derived from work_rights."},"salary_expectation_amount":{"type":"number","nullable":true,"description":"Amount in the candidate's own period derived from salary_expectation (850 for \"850/day\")."},"salary_expectation_period":{"type":"string","nullable":true,"enum":["year","day","hour"],"description":"Period derived from salary_expectation. Day and hour rates never fabricate an annual figure."},"preferred_roles":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Roles the candidate said they want next. Null = never captured."},"preferred_seniority":{"type":"string","nullable":true,"enum":["intern","junior","mid","senior","staff","lead","principal","head","director","vp","c_suite"],"description":"Seniority level the candidate wants."},"work_types":{"type":"array","items":{"type":"string","enum":["permanent","contract","temp","part_time"]},"nullable":true,"description":"Work types the candidate is open to."},"open_to_relocation":{"type":"boolean","nullable":true,"description":"Whether the candidate said they would relocate. Null = never captured."},"available_from":{"type":"string","format":"date","nullable":true,"description":"Stated availability date. Complements notice_period in availability search."},"preferred_locations":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Places the candidate said they want to work. Gazetteer-resolved for search."},"applications":{"type":"array","items":{"type":"object","properties":{"job_id":{"type":"string","nullable":true},"job_title":{"type":"string","nullable":true},"application_id":{"type":"string"},"status":{"type":"string"}}},"description":"Summary of all applications for this candidate."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"enriched_at":{"type":"string","format":"date-time","nullable":true}}},"Application":{"type":"object","properties":{"id":{"type":"string","example":"app_550e8400e29b41d4a716446655440000"},"job_id":{"type":"string","nullable":true},"job_title":{"type":"string","nullable":true},"candidate_id":{"type":"string"},"candidate_name":{"type":"string","nullable":true},"candidate_email":{"type":"string","nullable":true},"candidate_phone":{"type":"string","nullable":true},"candidate_location":{"type":"object","nullable":true,"description":"Coarsened to suburb (or city) and state - never a street address.","properties":{"city":{"type":"string"}}},"candidate_linkedin_url":{"type":"string","nullable":true},"candidate_cv_url":{"type":"string","nullable":true},"candidate_skills":{"type":"array","items":{"type":"string"}},"current_title":{"type":"string","nullable":true,"description":"Snapshot of the candidate current_title at the moment they applied. Never updated after insert."},"current_company":{"type":"string","nullable":true,"description":"Snapshot of the candidate current_company at the moment they applied. Never updated after insert."},"stage":{"type":"string","enum":["funnel","maybe","yes","no","screen","submitted","client_interview","references","offer","placed","rejected","withdrew"]},"status":{"type":"string","deprecated":true,"description":"Deprecated alias for stage. Use stage instead."},"source":{"type":"string","nullable":true,"description":"Canonical source category: Careers Page, Job Board, Referral, Talent Pool, Database Search, Social, Agency, Direct, Imported, or Other."},"source_detail":{"type":"string","nullable":true,"description":"Granular sub-source within the category - the exact board, referrer, pool, or search query."},"assessment":{"type":"object","nullable":true,"properties":{"job_fit_score":{"type":"number","minimum":0,"maximum":100},"hm_fit_score":{"type":"number","minimum":0,"maximum":100},"overall_recommendation":{"type":"string","enum":["strong_hire","hire","hold","talent_pool","reject"]},"summary":{"type":"string","nullable":true},"alternative_teams":{"type":"array","items":{"type":"string"}},"alternative_roles":{"type":"array","items":{"type":"string"}},"criteria_scores":{"type":"array","items":{"type":"object","properties":{"criterion":{"type":"string","nullable":true},"block":{"type":"string","nullable":true},"scoring_type":{"type":"string","nullable":true},"pass":{"type":"boolean","nullable":true},"scale_score":{"type":"number","nullable":true},"ai_reasoning":{"type":"string","nullable":true}}}},"version":{"type":"integer"},"assessed_at":{"type":"string","format":"date-time","nullable":true}}},"parsed_profile":{"$ref":"#/components/schemas/ParsedProfile"},"notes":{"type":"string","nullable":true},"shortlist_summary":{"type":"string","nullable":true,"description":"AI-generated 150-word briefing for the hiring manager. Populated when the application is triaged to yes."},"shortlist_summary_generated_at":{"type":"string","format":"date-time","nullable":true},"reference_summary":{"type":"string","nullable":true,"description":"AI-consolidated summary across all referee responses. Populated when the final expected referee has submitted their form."},"reference_flags":{"type":"array","nullable":true,"description":"Structured red flags detected in referee responses. Medium/high severity flags automatically enqueue a reference_red_flag review.","items":{"type":"object","properties":{"type":{"type":"string","enum":["integrity","performance","interpersonal","other"]},"referee":{"type":"string"},"quote":{"type":"string"},"severity":{"type":"string","enum":["low","medium","high"]}}}},"reference_summary_generated_at":{"type":"string","format":"date-time","nullable":true},"expected_reference_count":{"type":"integer","description":"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":{"type":"string","format":"date-time","nullable":true,"description":"When the candidate submitted their referee contacts through the Request References form."},"applied_at":{"type":"string","format":"date-time"},"status_updated_at":{"type":"string","format":"date-time"}}},"ReviewQueueItem":{"type":"object","properties":{"id":{"type":"string","example":"rev_550e8400e29b41d4a716446655440000"},"application_id":{"type":"string","nullable":true},"review_type":{"type":"string","enum":["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"]},"priority":{"type":"string","enum":["urgent","high","normal","low"]},"title":{"type":"string"},"description":{"type":"string","nullable":true},"action_required":{"type":"string","nullable":true},"resolved":{"type":"boolean"},"resolved_at":{"type":"string","format":"date-time","nullable":true},"snoozed_until":{"type":"string","format":"date-time","nullable":true,"description":"If set to a future time, the item is hidden from the default GET listing until this moment passes."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Submission":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"sub_550e8400e29b41d4a716446655440000"},"job_id":{"type":"string"},"job_title":{"type":"string","nullable":true,"description":"The job's title, embedded so list consumers never need a lookup per row."},"client_id":{"type":"string"},"client_name":{"type":"string","nullable":true,"description":"The client's name, embedded so list consumers never need a lookup per row."},"client_contact_id":{"type":"string"},"status":{"type":"string","enum":["pending","viewed","partially_responded","completed","expired","revoked"]},"title":{"type":"string","nullable":true},"email_subject":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","enum":["full","first_name","anonymous"],"description":"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":{"type":"string","description":"The client-facing review link. No login - anyone with the link can respond. Expires with the submission."},"expires_at":{"type":"string","format":"date-time","description":"7 days from send; resending extends it."},"first_viewed_at":{"type":"string","format":"date-time","nullable":true},"completed_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"items":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionItem"}}}},"SubmissionItem":{"type":"object","properties":{"id":{"type":"string","example":"sbi_550e8400e29b41d4a716446655440000"},"candidate_id":{"type":"string"},"candidate_name":{"type":"string","nullable":true},"application_id":{"type":"string","nullable":true},"summary":{"type":"string","nullable":true,"description":"The client-facing pitch for this candidate. AI-drafted when not supplied, consultant-editable before send."},"response":{"type":"string","nullable":true,"enum":["interview_requested","rejected",null],"description":"The client's verdict. Null until they respond."},"response_note":{"type":"string","nullable":true},"response_rating":{"type":"integer","nullable":true,"minimum":1,"maximum":5,"description":"Retired on 2026-08-16 in favour of availability_slots. Still returned for submissions answered before then; null on everything since."},"availability_slots":{"type":"array","description":"When the client can meet the candidate. Captured on the review portal in their own words (\"5pm tomorrow, Thursday morning\"), converted to real instants, and confirmed by them on screen before sending. Empty until they request an interview, and empty when they requested one without offering times.","items":{"type":"object","properties":{"start":{"type":"string","format":"date-time","description":"The absolute instant."},"tz":{"type":"string","example":"Australia/Sydney","description":"The IANA zone the client was reading the clock in."},"label":{"type":"string","example":"Mon 17 Aug, 5:00 pm","description":"What the client saw when they confirmed it."},"approximate":{"type":"boolean","description":"True when they named a part of the day rather than an hour (\"Thursday morning\"). `start` is then that period's start."}}}},"rejection_reason":{"type":"string","nullable":true,"enum":["skills_mismatch","experience","location","salary","culture","other",null]},"responded_at":{"type":"string","format":"date-time","nullable":true},"viewed_at":{"type":"string","format":"date-time","nullable":true}}},"Spec":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"spc_550e8400e29b41d4a716446655440000"},"candidate_id":{"type":"string"},"client_id":{"type":"string"},"client_contact_id":{"type":"string"},"status":{"type":"string","enum":["sent","viewed","interested","passed","converted","expired","revoked"]},"anonymous_profile":{"type":"object","description":"The client-facing profile, exactly as approved by the consultant (and scrubbed of identifying details).","properties":{"headline":{"type":"string","example":"Senior Quantity Surveyor - Tier 1 commercial"},"chips":{"type":"array","items":{"type":"string"}},"pitch":{"type":"string"},"why_client":{"type":"string","nullable":true}}},"email_subject":{"type":"string"},"terms_attached":{"type":"boolean","description":"Whether the agency terms of business PDF rode along - the introduction record."},"candidate_aware":{"type":"boolean","description":"The honest record: does the candidate know they were specced?"},"url":{"type":"string","description":"The client-facing profile link. No login - anyone with the link can respond. Expires with the spec (30 days)."},"sent_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"first_viewed_at":{"type":"string","format":"date-time","nullable":true},"responded_at":{"type":"string","format":"date-time","nullable":true},"response":{"type":"string","nullable":true,"enum":["interested","passed",null]},"response_note":{"type":"string","nullable":true},"response_rating":{"type":"integer","nullable":true,"minimum":1,"maximum":5,"description":"The client's 1-5 rating, captured when they mark interest. Null if not rated."},"converted_job_id":{"type":"string","nullable":true},"converted_application_id":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"Interview":{"type":"object","properties":{"id":{"type":"string","example":"ivw_550e8400e29b41d4a716446655440000"},"application_id":{"type":"string","nullable":true},"job":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"title":{"type":"string","nullable":true}}},"candidate":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string","nullable":true}}},"round":{"type":"integer","description":"Interview round number (1-based)."},"format":{"type":"string","enum":["phone","video","in_person","technical"],"description":"Interview format."},"flavour":{"type":"string","enum":["internal","client"],"description":"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":{"type":"string","nullable":true,"description":"The submission this client interview came from, when booked off client feedback (sub_ id)."},"status":{"type":"string","enum":["scheduled","completed","no_show","cancelled"],"description":"Current interview status. Passed and failed interviews both read completed - see outcome for the actual result."},"outcome":{"type":"string","enum":["pending","passed","failed","no_show","cancelled"],"description":"The interview result. Unlike status, this distinguishes a pass from a fail."},"scheduled_at":{"type":"string","format":"date-time","nullable":true},"candidate_confirmed_at":{"type":"string","format":"date-time","nullable":true,"description":"Set when the candidate confirms attendance (e.g. replying YES to the WhatsApp confirmation). Null = not confirmed yet. Read-only."},"duration_minutes":{"type":"integer","nullable":true},"video_link":{"type":"string","nullable":true},"location":{"type":"string","nullable":true,"description":"Where an in-person interview happens. Rides the calendar invite's LOCATION line and the invitation emails."},"interviewers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string","nullable":true}}}},"client_contacts":{"type":"array","description":"Client-side attendees on a client interview (cct_ ids). They receive the calendar invite and .ics at booking; they never constrain the offered times.","items":{"type":"object","properties":{"id":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"email":{"type":"string"}}}},"notes_for_candidate":{"type":"string","nullable":true},"notes_for_interviewer":{"type":"string","nullable":true},"feedback":{"type":"object","nullable":true,"description":"Structured interview feedback (JSONB). Populated by the unified interview form on submit: { recommendation, feedback, scores, decision_criteria, section_breakdown }."},"reminder_sent_at":{"type":"string","format":"date-time","nullable":true,"description":"Set by the interview-reminder cron when the 24h reminder emails are sent."},"feedback_requested_at":{"type":"string","format":"date-time","nullable":true,"description":"First time the hiring manager was asked for feedback after the interview ended."},"feedback_chase_count":{"type":"integer","description":"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":{"type":"string","nullable":true,"description":"Google Calendar event id when the interviewer has a connected calendar. Changes made in Google Calendar flow back to this row."},"calendar_provider":{"type":"string","nullable":true,"enum":["google",null],"description":"Which calendar provider is authoritative for this interview."},"created_at":{"type":"string","format":"date-time"}}},"Outreach":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"otr_550e8400e29b41d4a716446655440000"},"candidate_id":{"type":"string"},"job_id":{"type":"string","nullable":true},"platform":{"type":"string","enum":["email","linkedin","whatsapp"]},"subject":{"type":"string","nullable":true,"description":"Email only."},"body":{"type":"string"},"status":{"type":"string","enum":["draft","sent","replied","no_response"]},"sent_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"Placement":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"pla_550e8400e29b41d4a716446655440000"},"candidate_id":{"type":"string"},"candidate_name":{"type":"string","nullable":true,"description":"The candidate's name, embedded so list consumers never need a lookup per row."},"client_id":{"type":"string"},"client_name":{"type":"string","nullable":true,"description":"The client's name, embedded so list consumers never need a lookup per row."},"job_id":{"type":"string","nullable":true},"job_title":{"type":"string","nullable":true},"application_id":{"type":"string","nullable":true},"salary":{"type":"number","nullable":true,"description":"First-year salary. Requires the placements:financials:read scope - null without it."},"currency":{"type":"string","nullable":true,"description":"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":{"type":"object","description":"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.","properties":{"type":{"type":"string","enum":["percent","fixed"]},"percent":{"type":"number","nullable":true},"amount":{"type":"number","nullable":true}}},"placement_type":{"type":"string","enum":["permanent","contract"],"description":"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":{"type":"string","format":"date","nullable":true,"description":"When the offer was accepted. Defaults to the day the placement is logged."},"contract":{"type":"object","nullable":true,"description":"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.","properties":{"end_date":{"type":"string","format":"date","nullable":true},"charge_rate":{"type":"number","nullable":true,"description":"Requires the placements:financials:read scope - null without it."},"pay_rate":{"type":"number","nullable":true,"description":"Requires the placements:financials:read scope - null without it."},"rate_period":{"type":"string","enum":["hourly","daily","weekly"],"nullable":true},"expected_gp":{"type":"number","nullable":true,"description":"Expected gross profit over the initial term, in the placement's currency. Requires the placements:financials:read scope - null without it."}}},"credits":{"type":"array","description":"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.","items":{"type":"object","properties":{"user_id":{"type":"string"},"role":{"type":"string","enum":["candidate_consultant","job_owner","business_development","other"],"nullable":true},"percent":{"type":"number"}}}},"start_date":{"type":"string","format":"date","nullable":true},"guarantee_days":{"type":"integer","nullable":true,"description":"Guarantee period in days from start_date. Defaults from the client's fee schedule when omitted on create."},"status":{"type":"string","enum":["pending_start","started","fell_off","completed"],"description":"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":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"QuotaPlan":{"type":"object","description":"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.","properties":{"cadence":{"type":"string","enum":["monthly","quarterly","yearly"]},"currency":{"type":"string","description":"ISO 4217 code targets are measured in. One currency per plan; other currencies are reported as their own lines, never converted."},"recognition_basis":{"type":"string","enum":["offer_accepted","start_date"],"description":"When a placement counts: when the offer was accepted, or when the candidate starts (default)."},"include_permanent":{"type":"boolean"},"include_contract":{"type":"boolean","description":"Contract placements count their expected GP; perm placements count the fee."},"extensions_rule":{"type":"string","enum":["extension_start","not_counted"]},"quarter_start_month":{"type":"integer","minimum":1,"maximum":12,"description":"First month of the cycle. 1 = Jan/Apr/Jul/Oct quarters; 7 = an Australian financial year."},"lock_closed_periods":{"type":"boolean"}}},"QuotaPeriod":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"qpd_550e8400e29b41d4a716446655440000"},"label":{"type":"string","example":"Q3 2026"},"start_date":{"type":"string","format":"date","description":"Inclusive."},"end_date":{"type":"string","format":"date","description":"Inclusive."},"status":{"type":"string","enum":["open","published","locked"]},"agency_target":{"type":"number","nullable":true,"description":"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":{"type":"string","format":"date-time","nullable":true}}},"QuotaMoney":{"type":"array","description":"Money as one line per currency, largest first. Lovelio never converts between currencies. currency is null only when the underlying records named no currency at all.","items":{"type":"object","properties":{"currency":{"type":"string","nullable":true},"amount":{"type":"number"}}}},"QuotaBoardRow":{"type":"object","properties":{"user_id":{"type":"string","description":"Workspace user id - the same id placement credits carry."},"name":{"type":"string"},"booked":{"$ref":"#/components/schemas/QuotaMoney"},"placements":{"type":"integer","description":"Placements carrying a credit for this consultant in the period."},"target":{"type":"number","nullable":true,"description":"Null = no target set."}}},"MarketplaceListing":{"type":"object","description":"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).","properties":{"id":{"type":"string","example":"mkl_550e8400e29b41d4a716446655440000"},"kind":{"type":"string","enum":["job","candidate"]},"status":{"type":"string","enum":["live","paused","filled","expired","withdrawn"]},"headline":{"type":"string","description":"Anonymised headline, e.g. \"Senior auditor, top-tier firm, Melbourne\"."},"chips":{"type":"array","items":{"type":"string"},"description":"Up to 6 anonymised fact chips."},"pitch":{"type":"string","description":"The anonymised pitch. Deterministically scrubbed and moderated before it crosses tenants."},"location":{"type":"string","nullable":true},"agency":{"type":"object","description":"The posting agency - always visible (anonymity protects clients and candidates, never the trading agencies).","properties":{"name":{"type":"string"},"location":{"type":"string","nullable":true}}},"salary_min":{"type":"number","nullable":true},"salary_max":{"type":"number","nullable":true},"currency":{"type":"string","nullable":true},"fee_percent":{"type":"number","nullable":true,"description":"The declared fee basis (percent of first-year salary)."},"owner_share_percent":{"type":"integer","enum":[50,60,70,80],"description":"The split the owner keeps. Lovelio takes 0% - the fee is split between the two agencies, full stop."},"partner_share_percent":{"type":"integer","description":"Always 100 - owner_share_percent."},"mine":{"type":"boolean","description":"True when your agency owns this listing."},"source_job_id":{"type":"string","nullable":true,"description":"The job behind your own listing (job_). Always null on other agencies' listings."},"source_candidate_id":{"type":"string","nullable":true,"description":"The candidate behind your own listing (cnd_). Always null on other agencies' listings."},"expires_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"MarketplaceIntro":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"mki_550e8400e29b41d4a716446655440000"},"listing_id":{"type":"string","nullable":true},"listing_headline":{"type":"string"},"kind":{"type":"string","enum":["submission","request"]},"direction":{"type":"string","enum":["received","sent"],"description":"received = an intro on your listing; sent = an intro your agency made."},"status":{"type":"string","enum":["pending","accepted","declined","withdrawn","expired"]},"from_agency":{"type":"object","properties":{"name":{"type":"string"},"consultant":{"type":"string","nullable":true}}},"candidate_id":{"type":"string","nullable":true,"description":"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":{"type":"object","nullable":true,"properties":{"headline":{"type":"string"},"chips":{"type":"array","items":{"type":"string"}},"pitch":{"type":"string"}}},"note":{"type":"string","nullable":true},"fit_summary":{"type":"string","nullable":true,"description":"AI facts-to-check against your listing (received submissions only). Facts, never verdicts."},"attested_relationship":{"type":"boolean","description":"The submitter attested they interviewed the candidate and the candidate agreed to be represented."},"decline_reason":{"type":"string","nullable":true},"prior_knowledge":{"type":"boolean","nullable":true,"description":"Stamped at accept time: whether the receiving agency already knew the candidate (checked BEFORE the reveal)."},"responded_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"MarketplaceDeal":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"mkd_550e8400e29b41d4a716446655440000"},"intro_id":{"type":"string","nullable":true},"listing_id":{"type":"string","nullable":true},"listing_kind":{"type":"string","enum":["job","candidate"]},"listing_headline":{"type":"string"},"status":{"type":"string","enum":["connected","in_progress","placed","dead"],"description":"Derived from the real pipeline: client-side stages read as in_progress, a placement as placed, a dead application kills an unplaced deal."},"role":{"type":"string","enum":["owner","partner"],"description":"Your side of this deal: owner listed it, partner made the intro."},"counterpart_agency":{"type":"string","description":"The other agency on the deal."},"owner_share_percent":{"type":"integer","enum":[50,60,70,80]},"your_share_percent":{"type":"integer"},"fee_percent":{"type":"number","nullable":true},"salary_min":{"type":"number","nullable":true},"salary_max":{"type":"number","nullable":true},"currency":{"type":"string","nullable":true},"terms_version":{"type":"string","description":"The Split Terms version both agencies accepted."},"revealed":{"type":"object","nullable":true,"description":"The identity snapshot written once at accept: candidate name and contact, plus both consultants' contacts."},"revealed_at":{"type":"string","format":"date-time","nullable":true},"protection_ends_at":{"type":"string","format":"date-time","description":"The 12-month introduction protection window."},"placement_id":{"type":"string","nullable":true,"description":"The owner's placement record (pla_). Null for the partner - the deal itself carries the shared money snapshot."},"placement_status":{"type":"string","nullable":true,"enum":["pending_start","started","fell_off","completed",null]},"placed_salary":{"type":"number","nullable":true,"description":"Requires the marketplace:financials:read scope - null without it."},"placed_fee_amount":{"type":"number","nullable":true,"description":"Requires the marketplace:financials:read scope - null without it."},"placed_currency":{"type":"string","nullable":true,"description":"The placement's real currency - render placed money in this. `currency` stays the listing's asking currency."},"partner_share_amount":{"type":"number","nullable":true,"description":"Requires the marketplace:financials:read scope - null without it."},"your_share_amount":{"type":"number","nullable":true,"description":"Your side of the placed fee, per the locked split. Requires the marketplace:financials:read scope - null without it."},"placed_start_date":{"type":"string","format":"date","nullable":true},"owner_marked_paid_at":{"type":"string","format":"date-time","nullable":true,"description":"The paid/received handshake: the placing agency's stamp."},"partner_marked_received_at":{"type":"string","format":"date-time","nullable":true,"description":"The paid/received handshake: the receiving agency's stamp."},"closed_at":{"type":"string","format":"date-time","nullable":true,"description":"Set when an unplaced deal was deliberately closed."},"closed_by":{"type":"string","nullable":true,"enum":["you","counterpart",null],"description":"Which side closed it, from your point of view."},"closed_reason":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"BdTarget":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"bdt_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"employer_name":{"type":"string"},"market":{"type":"string","enum":["AU","UK","US","CA"]},"status":{"type":"string","enum":["new","pursuing","dismissed","converted"]},"lookalike_score":{"type":"integer","description":"0-100, deterministic fit against the agency's history"},"right_to_win":{"type":"string","nullable":true,"description":"One line: why this agency in particular"},"score_facts":{"type":"object","description":"The evidence behind the score (worked families, salary bands, ad facts)"},"matched_client_id":{"type":"string","nullable":true},"pursued_at":{"type":"string","format":"date-time","nullable":true},"dismissed_at":{"type":"string","format":"date-time","nullable":true},"converted_at":{"type":"string","format":"date-time","nullable":true},"converted_client_id":{"type":"string","nullable":true},"last_scored_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"BdLead":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"bdl_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"employer_name":{"type":"string"},"market":{"type":"string","enum":["AU","UK","US","CA","OTHER"],"description":"OTHER means the lead was captured by hand from an ad outside the markets Lovelio sweeps; it carries no currency."},"kind":{"type":"string","enum":["new_business","client_expansion"]},"status":{"type":"string","enum":["open","dismissed","converted"]},"score":{"type":"integer"},"fit_score":{"type":"integer","nullable":true},"signals":{"type":"array","items":{"type":"string"}},"why_now":{"type":"string","nullable":true},"why_you":{"type":"string","nullable":true},"fit_note":{"type":"string","nullable":true},"est_fee":{"type":"number","nullable":true},"fee_basis":{"type":"string","nullable":true},"currency":{"type":"string","nullable":true},"matched_client_id":{"type":"string","nullable":true},"surfaced_on":{"type":"string","format":"date"},"dismissed_at":{"type":"string","format":"date-time","nullable":true},"converted_at":{"type":"string","format":"date-time","nullable":true},"converted_client_id":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Webhook":{"type":"object","properties":{"id":{"type":"string","example":"whk_550e8400e29b41d4a716446655440000"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","paused"]},"created_at":{"type":"string","format":"date-time"}}},"ParsedProfile":{"type":"object","nullable":true,"description":"Structured representation of a parsed CV/resume.","properties":{"summary":{"type":"string","nullable":true},"experience":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string","nullable":true},"company":{"type":"string","nullable":true},"start_date":{"type":"string","nullable":true},"end_date":{"type":"string","nullable":true},"is_current":{"type":"boolean"},"description":{"type":"string","nullable":true}}}},"education":{"type":"array","items":{"type":"object","properties":{"institution":{"type":"string","nullable":true},"degree":{"type":"string","nullable":true},"start_date":{"type":"string","nullable":true},"end_date":{"type":"string","nullable":true}}}},"skills":{"type":"array","items":{"type":"string"}},"parsed_at":{"type":"string","format":"date-time","nullable":true}}},"Task":{"type":"object","properties":{"task_id":{"type":"string","example":"tsk_550e8400e29b41d4a716446655440000"},"status":{"type":"string","enum":["queued","processing","completed","failed"]},"resource_id":{"type":"string","nullable":true},"estimated_seconds":{"type":"integer"},"poll_url":{"type":"string"}}},"Activity":{"type":"object","properties":{"id":{"type":"string","example":"act_abc123"},"account_id":{"type":"string","example":"acc_abc123"},"resource_type":{"type":"string","enum":["job","job_ad","candidate","application","interview","placement"]},"resource_id":{"type":"string","example":"app_abc123"},"action":{"type":"string","example":"application.stage_changed"},"activity_type":{"type":"string","enum":["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"]},"actor_type":{"type":"string","enum":["user","bot","system","automation","api"]},"actor_id":{"type":"string","nullable":true},"actor_email":{"type":"string","nullable":true},"description":{"type":"string"},"metadata":{"type":"object","description":"Activity-specific data (scores, field changes, etc.)"},"created_at":{"type":"string","format":"date-time"}}},"Document":{"type":"object","description":"A file attached to any record. Documents are polymorphic: the same endpoints serve candidates, clients, contacts, jobs, placements, talent pools and more.","properties":{"id":{"type":"string","example":"doc_abc123"},"account_id":{"type":"string","example":"acc_abc123"},"record_type":{"type":"string","enum":["candidate","client","client_contact","job","job_ad","application","interview","placement","submission","spec","spec_submission","bd_target","talent_pool","team","hiring_manager"],"description":"The kind of record this document is filed against."},"record_id":{"type":"string","example":"cnd_abc123","description":"External ID of the record it belongs to."},"file_name":{"type":"string","example":"Jane Smith CV 2026.pdf"},"content_type":{"type":"string","example":"application/pdf"},"size_bytes":{"type":"integer","nullable":true},"source":{"type":"string","enum":["upload","migration","cv"],"description":"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":{"type":"boolean","description":"Candidates only: this is the current CV, the file the resume parser and cv_url read."},"uploaded_by_email":{"type":"string","nullable":true},"source_created_at":{"type":"string","format":"date-time","nullable":true,"description":"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":{"type":"string","format":"date-time"}}},"Form":{"type":"object","properties":{"id":{"type":"string","example":"frm_550e8400e29b41d4a716446655440000"},"name":{"type":"string","example":"Phone Screen"},"category":{"type":"string","enum":["screening","assessment","interview","decision","offer","references","onboarding","compliance"]},"description":{"type":"string","nullable":true},"ai_augmentation_enabled":{"type":"boolean"},"is_default":{"type":"boolean"},"question_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"}}},"FormQuestion":{"type":"object","properties":{"id":{"type":"string","example":"fqu_550e8400e29b41d4a716446655440000"},"order_index":{"type":"integer"},"question_text":{"type":"string"},"question_type":{"type":"string","enum":["text","rating","yes_no","multiple_choice"]},"options":{"type":"array","items":{"type":"string"},"nullable":true},"scoring_weight":{"type":"number","nullable":true},"flag_mode":{"type":"string","enum":["red","green","neutral"],"nullable":true},"required":{"type":"boolean"}}},"FormInstance":{"type":"object","properties":{"id":{"type":"string","example":"fin_550e8400e29b41d4a716446655440000"},"form_id":{"type":"string"},"instance_type":{"type":"string","enum":["screening","reference"]},"status":{"type":"string","enum":["draft","sent","opened","started","completed"]},"job_application_id":{"type":"string","nullable":true},"referee_id":{"type":"string","nullable":true},"sent_at":{"type":"string","format":"date-time","nullable":true},"opened_at":{"type":"string","format":"date-time","nullable":true},"completed_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"Referee":{"type":"object","properties":{"id":{"type":"string","example":"ref_550e8400e29b41d4a716446655440000"},"candidate_id":{"type":"string"},"name":{"type":"string","example":"Sarah Manager"},"email":{"type":"string","format":"email"},"phone":{"type":"string","nullable":true,"description":"Mobile number."},"job_title":{"type":"string","nullable":true},"company":{"type":"string","nullable":true,"description":"Where the candidate and referee worked together."},"relationship":{"type":"string","nullable":true},"worked_from":{"type":"string","nullable":true,"description":"Start of the period they worked together, YYYY-MM."},"worked_to":{"type":"string","nullable":true,"description":"End of the period they worked together, YYYY-MM."},"how_worked_together":{"type":"string","nullable":true},"source":{"type":"string","enum":["manual","resume_parse","candidate_submitted"],"description":"How the referee was added."},"submitted_at":{"type":"string","format":"date-time","nullable":true,"description":"When the candidate submitted this referee via the Request References form."},"created_at":{"type":"string","format":"date-time"}}},"EmailTemplate":{"type":"object","description":"Merged view of a canonical email template and its per-company override (if any).","properties":{"template_key":{"type":"string","example":"application_acknowledgement"},"name":{"type":"string"},"category":{"type":"string","enum":["candidate","hiring_manager","referee"]},"trigger":{"type":"string"},"description":{"type":"string"},"variables":{"type":"array","items":{"type":"string"}},"default_instructions":{"type":"string"},"ai_instructions":{"type":"string","nullable":true,"description":"Per-company override of the default_instructions. null when no override exists."},"enabled":{"type":"boolean","description":"Whether Lovelio should send this template. Defaults to true."},"has_override":{"type":"boolean"},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkflowRules":{"type":"object","description":"Per-company configuration for how Lovelio scores, triages, and follows up on applications.","properties":{"ai_assessment_job_fit_advance":{"type":"integer","minimum":0,"maximum":100,"description":"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":{"type":"integer","minimum":0,"maximum":100,"description":"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":{"type":"integer","minimum":0,"maximum":100,"description":"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":{"type":"integer","minimum":0,"maximum":100,"description":"Client Fit score at or below which a new application receives the AI verdict \"no\" (default 50)."},"screen_advance_threshold":{"type":"integer","minimum":0,"maximum":100,"description":"Phone screen score at or above which Lovelio recommends submitting the candidate to the client. Recommendations never move a candidate automatically."},"screen_reject_threshold":{"type":"integer","minimum":0,"maximum":100,"description":"Phone screen score at or below which Lovelio recommends parking the candidate."},"submission_chase_days":{"type":"integer","minimum":1,"maximum":14,"description":"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":{"type":"integer","minimum":1,"maximum":720,"description":"Hours after a scheduled interview before the interviewer gets a feedback nudge."},"referee_details_chase_days":{"type":"integer","minimum":0,"maximum":60},"referee_details_escalate_days":{"type":"integer","minimum":0,"maximum":60},"referee_form_chase_days":{"type":"integer","minimum":1,"maximum":14,"description":"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":{"type":"integer","minimum":1,"maximum":5,"description":"Default number of referees requested per candidate. Adjustable per request."},"hot_candidate_job_fit_threshold":{"type":"integer","minimum":50,"maximum":100,"description":"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":{"type":"integer","minimum":50,"maximum":100,"description":"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":{"type":"boolean","description":"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":{"type":"integer","minimum":1,"maximum":14,"description":"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":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"CalendarEvent":{"type":"object","properties":{"id":{"type":"string","example":"cal_550e8400e29b41d4a716446655440000"},"title":{"type":"string","example":"Interview: Alex Morgan"},"notes":{"type":"string","nullable":true},"event_type":{"type":"string","enum":["interview","phone_screen","reminder","deadline","meeting"]},"starts_at":{"type":"string","format":"date-time"},"ends_at":{"type":"string","format":"date-time","nullable":true},"all_day":{"type":"boolean"},"application_id":{"type":"string","nullable":true},"job_id":{"type":"string","nullable":true},"candidate_id":{"type":"string","nullable":true},"assigned_to":{"type":"string","nullable":true,"description":"User ID the event is assigned to."},"created_by":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Account":{"type":"object","properties":{"id":{"type":"string","example":"acc_550e8400e29b41d4a716446655440000"},"company_name":{"type":"string","example":"Acme Corp"},"slug":{"type":"string","example":"acme-corp"},"domain":{"type":"string","nullable":true},"logo_url":{"type":"string","nullable":true},"brand_colours":{"type":"array","items":{"type":"string"}},"location":{"type":"string","nullable":true},"tone_of_voice":{"type":"string","nullable":true},"seats":{"type":"integer","nullable":true,"description":"Paid seat count. Null while trialing (before first checkout)."},"plan_tier":{"type":"string","enum":["premium"],"example":"premium","description":"Legacy field. Lovelio has one plan with everything included, so this always reads premium."},"plan_status":{"type":"string","enum":["trialing","active","past_due","cancelled"],"example":"trialing"},"email_verified":{"type":"boolean"},"status":{"type":"string","example":"active"},"website_url":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"enriched_at":{"type":"string","format":"date-time","nullable":true},"brand_voice_prompt":{"type":"string","nullable":true},"mission":{"type":"string","nullable":true,"description":"Company mission - 1-2 sentences. Auto-extracted from the company website on signup; editable in the dashboard."},"what_we_do":{"type":"string","nullable":true,"description":"What problems the company solves and for whom - 1-2 sentences. Auto-extracted; editable."},"why_join":{"type":"string","nullable":true,"description":"Bullet list of reasons to work at the company. Auto-extracted; editable."},"benefits":{"type":"string","nullable":true,"description":"Bullet list of tangible benefits and perks. Auto-extracted; editable. Job ad generation never invents items not in this block."},"company_values":{"type":"string","nullable":true,"description":"Bullet list of cultural anchors / values that define how the team works."},"role_intelligence":{"type":"object","nullable":true,"description":"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":{"type":"string","format":"date-time","nullable":true,"description":"Set when the account holder finishes the in-app onboarding wizard. Null means the next dashboard visit will force the wizard."},"onboarding_answers":{"type":"object","description":"Raw answers captured by the onboarding wizard. Keys: logoConfirmed (boolean)."},"timezone":{"type":"string","example":"Europe/London","description":"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":{"type":"string","enum":["default","manual","brandfetch","firecrawl","browser"],"description":"How the timezone was set. \"manual\" means an admin or API partner set it explicitly and no automated process will overwrite it."},"is_demo":{"type":"boolean","description":"True for throwaway demo accounts created by the MagicHire team. Always false for real customer accounts created via /v1/accounts/signup."}}},"User":{"type":"object","properties":{"id":{"type":"string","example":"usr_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"name":{"type":"string","example":"Alex Morgan"},"first_name":{"type":"string","nullable":true,"example":"Alex"},"last_name":{"type":"string","nullable":true,"example":"Morgan"},"email":{"type":"string","format":"email"},"role":{"type":"string","enum":["admin","team_leader","recruiter"]},"team_ids":{"type":"array","items":{"type":"string"},"description":"The team the accepted member sits on. Always [] while their invite is pending."},"status":{"type":"string","example":"active"},"slack_user_id":{"type":"string","nullable":true},"whatsapp_number":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"linkedin_url":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"ApiKey":{"type":"object","properties":{"id":{"type":"string","example":"key_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"name":{"type":"string","example":"Production key"},"prefix":{"type":"string","example":"sk_live_abc"},"scopes":{"type":"array","items":{"type":"string"}},"status":{"type":"string","example":"active"},"key_type":{"type":"string","example":"live"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"SsoConnection":{"type":"object","description":"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.","properties":{"id":{"type":"string","example":"sso_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"provider":{"type":"string","enum":["google","microsoft","saml"],"description":"google = Google Workspace, microsoft = Microsoft Entra / Azure AD, saml = any generic SAML 2.0 IdP (Okta, JumpCloud, OneLogin, etc)."},"domain":{"type":"string","example":"acme.com"},"status":{"type":"string","enum":["pending","active","disabled"]},"workos_organization_id":{"type":"string","nullable":true,"description":"WorkOS organization ID. Used to generate Admin Portal setup URLs."},"created_at":{"type":"string","format":"date-time"},"last_verified_at":{"type":"string","format":"date-time","nullable":true}}},"Team":{"type":"object","properties":{"id":{"type":"string","example":"tem_550e8400e29b41d4a716446655440000"},"account_id":{"type":"string"},"name":{"type":"string","example":"Engineering"},"parent_team_id":{"type":"string","nullable":true,"description":"Parent team this team nests under in the org chart, or null at top level."},"created_at":{"type":"string","format":"date-time"}}},"TalentPool":{"type":"object","properties":{"id":{"type":"string","example":"pool_550e8400e29b41d4a716446655440000"},"name":{"type":"string","example":"Engineering"},"slug":{"type":"string","example":"engineering"},"eligibility":{"type":"string","nullable":true,"example":"AI engineers in Sydney","description":"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":{"type":"boolean","description":"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":{"type":"integer","nullable":true,"minimum":0,"maximum":100,"description":"Optional AI assessment score floor gating the match."},"min_hm_fit":{"type":"integer","nullable":true,"minimum":0,"maximum":100,"description":"Optional Client Fit score floor gating the match (the field name is historical)."},"description":{"type":"string","nullable":true},"who_we_hire":{"type":"string","nullable":true},"video_url":{"type":"string","nullable":true},"is_published":{"type":"boolean"},"keep_warm_enabled":{"type":"boolean","description":"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":{"type":"string","nullable":true,"enum":["cyan","green","lime","amber","orange",null],"description":"Chosen cover-tile colour for the dashboard. Null falls back to a colour derived from the pool name."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"ScheduledEmail":{"type":"object","properties":{"id":{"type":"string","example":"sem_550e8400e29b41d4a716446655440000"},"candidate_id":{"type":"string"},"application_id":{"type":"string","nullable":true},"job_id":{"type":"string","nullable":true},"thread_id":{"type":"string","nullable":true},"email_message_id":{"type":"string","nullable":true},"created_by":{"type":"string","nullable":true},"to_address":{"type":"string"},"cc_addresses":{"type":"array","items":{"type":"string"}},"bcc_addresses":{"type":"array","items":{"type":"string"}},"subject":{"type":"string"},"body_html":{"type":"string","nullable":true},"body_text":{"type":"string","nullable":true},"status":{"type":"string","enum":["draft","scheduled","sending","sent","cancelled","failed"]},"scheduled_at":{"type":"string","format":"date-time"},"sent_at":{"type":"string","format":"date-time","nullable":true},"cancelled_at":{"type":"string","format":"date-time","nullable":true},"last_error":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"WebhookDelivery":{"type":"object","properties":{"id":{"type":"string","example":"dlv_550e8400e29b41d4a716446655440000"},"webhook_id":{"type":"string"},"event":{"type":"string"},"status":{"type":"string","example":"delivered"},"attempts":{"type":"integer"},"last_response_code":{"type":"integer","nullable":true},"last_error":{"type":"string","nullable":true},"next_retry_at":{"type":"string","format":"date-time","nullable":true},"payload":{"type":"object"},"created_at":{"type":"string","format":"date-time"}}}},"parameters":{"limit":{"name":"limit","in":"query","description":"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.","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},"after":{"name":"after","in":"query","schema":{"type":"string"},"description":"Cursor for forward pagination"},"before":{"name":"before","in":"query","schema":{"type":"string"},"description":"Cursor for backward pagination"},"createdAfter":{"name":"created_after","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only rows created at or after this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored."},"createdBefore":{"name":"created_before","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only rows created at or before this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored."}},"headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix timestamp when window resets"}}},"paths":{"/health":{"get":{"summary":"Health check","operationId":"getHealth","tags":["System"],"security":[],"responses":{"200":{"description":"OK"}}},"post":{"summary":"Health check (POST)","operationId":"postHealth","tags":["System"],"security":[],"description":"Same as GET. POST is accepted so this endpoint can be used as a webhook test target.","responses":{"200":{"description":"OK"}}}},"/accounts/signup":{"post":{"summary":"Create account","operationId":"createAccount","tags":["Accounts"],"security":[],"description":"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.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","description":"Work email address. Free/personal domains (gmail etc.) are refused."},"source":{"type":"string","description":"Where the signup came from (e.g. \"claude-agent\"). Defaults to \"api\"."}}}}}},"responses":{"202":{"description":"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 }.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"account_id":{"type":"string"},"status":{"type":"string","example":"pending_verification"},"trial_api_key":{"type":"string","description":"sk_trial_ key, 7-day expiry. Shown only once; an account admin can mint replacements via POST /accounts/{id}/api-keys."},"email_verified":{"type":"boolean"},"message":{"type":"string"},"enrichment_task_id":{"type":"string","nullable":true}}}}}}}},"409":{"description":"Another account already claims this email or its domain (error.code CONFLICT)."},"422":{"description":"Missing/invalid email, or a free-email domain."},"429":{"description":"Rate limit exceeded (3/hour, 5/day per IP). Retry-After header set."}}}},"/accounts/verify-email":{"post":{"summary":"Verify account email","operationId":"verifyAccountEmail","tags":["Accounts"],"security":[],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"Verification token from the email link."}}}}}},"responses":{"200":{"description":"Email verified, account active","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"account_id":{"type":"string"},"email_verified":{"type":"boolean"},"status":{"type":"string","example":"active"}}}}}}}},"409":{"description":"Token already used"},"422":{"description":"Token missing, invalid, or expired"}}}},"/accounts/me":{"get":{"summary":"Get current account","operationId":"getMyAccount","tags":["Accounts"],"responses":{"200":{"description":"Account details","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"company_name":{"type":"string"},"slug":{"type":"string"},"domain":{"type":"string","nullable":true},"logo_url":{"type":"string","nullable":true},"brand_colours":{"type":"array","items":{"type":"string"}},"brand_voice_prompt":{"type":"string","nullable":true},"mission":{"type":"string","nullable":true},"what_we_do":{"type":"string","nullable":true},"why_join":{"type":"string","nullable":true},"benefits":{"type":"string","nullable":true},"company_values":{"type":"string","nullable":true},"role_intelligence":{"type":"object","nullable":true,"description":"Operational role data. Keys: rolesWePlaceInput, rolesWePlace, roleIntelligence, roleTemplates."},"onboarding_completed_at":{"type":"string","format":"date-time","nullable":true,"description":"Set when the account holder finishes the in-app onboarding wizard (or programmatically via PATCH). Null means the next dashboard visit will force the wizard."},"onboarding_answers":{"type":"object","description":"Raw answers captured by the onboarding wizard. Keys: logoConfirmed (boolean)."},"timezone":{"type":"string","example":"Europe/London","description":"IANA timezone used for company-wide scheduled work."},"timezone_source":{"type":"string","enum":["default","manual","brandfetch","firecrawl","browser"]},"seats":{"type":"integer","nullable":true},"plan_tier":{"type":"string","enum":["premium"],"description":"Legacy field. Lovelio has one plan; always premium."},"plan_status":{"type":"string","enum":["trialing","active","past_due","cancelled"]},"email_verified":{"type":"boolean"},"status":{"type":"string"},"website_url":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"enriched_at":{"type":"string","format":"date-time","nullable":true}}}}}}}}}}},"/accounts/{id}":{"get":{"summary":"Get account","operationId":"getAccount","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Account details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Account"}}}}}}}},"patch":{"summary":"Update account","operationId":"updateAccount","tags":["Accounts"],"description":"Update mutable fields on the account. Partners can also mark onboarding as complete to bypass the in-app wizard.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"company_name":{"type":"string"},"tone_of_voice":{"type":"string","nullable":true},"logo_url":{"type":"string","nullable":true},"website_url":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"onboarding_completed_at":{"description":"Pass true to mark onboarding complete (server sets NOW()), false/null to clear it, or an ISO 8601 timestamp to set it exactly.","oneOf":[{"type":"boolean"},{"type":"string","format":"date-time"},{"type":"null"}]},"timezone":{"type":"string","example":"America/New_York","description":"IANA timezone (e.g. \"Europe/London\"). Setting this marks timezone_source as \"manual\" so automated enrichment will not overwrite it."}}}}}},"responses":{"200":{"description":"Updated account","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Account"}}}}}}}}},"/jobs":{"get":{"summary":"List jobs","operationId":"listJobs","tags":["Jobs"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"status","in":"query","schema":{"type":"string","enum":["active","on_hold","closed","filled"]}},{"name":"client_id","in":"query","description":"Filter to jobs for one client (cli_ id).","schema":{"type":"string"}},{"name":"team_id","in":"query","schema":{"type":"string"}},{"name":"title","in":"query","description":"Case-insensitive substring match on the job title.","schema":{"type":"string"}},{"$ref":"#/components/parameters/createdAfter"},{"$ref":"#/components/parameters/createdBefore"}],"responses":{"200":{"description":"Paginated list of jobs","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Job"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create job (async)","operationId":"createJob","tags":["Jobs"],"description":"Creates a job and triggers AI enrichment (description, criteria, interview questions). Returns 202 with a task ID to poll.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["title","client_id"],"properties":{"title":{"type":"string"},"mode":{"type":"string","enum":["draft","active"],"description":"Create as draft or active. Default active."},"source":{"type":"string","description":"Provenance label stored on the create task. Defaults to \"api\"."},"source_context":{"type":"string","nullable":true,"description":"Free-text context stored alongside source."},"compensation_research":{"type":"boolean","default":false,"description":"When true, the create task also runs compensation research for the role."},"client_id":{"type":"string","description":"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":{"type":"string","description":"Contact at the client (cct_ id). Must belong to client_id."},"employment_type":{"type":"string","enum":["full_time","part_time","contract"],"description":"Basis of the role. Omit to leave unset. Any other value returns 422."},"compensation":{"type":"object","description":"Salary band for the role.","properties":{"base_min":{"type":"number","nullable":true},"base_max":{"type":"number","nullable":true},"currency":{"type":"string","description":"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."},"period":{"type":"string","enum":["year","day","hour"],"description":"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":{"type":"string","nullable":true,"description":"What great looks like 90 days in. Feeds AI enrichment of the job criteria."},"location":{"type":"object","description":"Structured location. `type` is one of `on_site`, `hybrid`, `remote`. For remote roles, city/country/country_code are ignored.","properties":{"type":{"type":"string","enum":["on_site","hybrid","remote"]},"city":{"type":"string","nullable":true},"state":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"country_code":{"type":"string","nullable":true,"description":"ISO 3166-1 alpha-2"}}},"team_id":{"type":"string"},"hiring_manager_id":{"type":"string"},"default_interview_format":{"type":"string","enum":["video","phone","in_person","technical"],"default":"video","description":"Default interview format used when auto-booking interviews on stage transition."},"is_evergreen":{"type":"boolean","default":false,"description":"Create as an always-open pipeline job, excluded from performance metrics."}}}}}},"responses":{"202":{"description":"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.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Task"}}}}}}}}},"/jobs/from-description":{"post":{"summary":"Draft a job from a natural-language description (streaming)","operationId":"draftJobFromDescription","tags":["Jobs"],"description":"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: <json>\\n\\n` where <json> 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).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["description"],"properties":{"description":{"type":"string","minLength":10,"maxLength":4000,"description":"Natural-language description of the role.","example":"Senior backend engineer in London, £120k-£140k, leading our payments rewrite."},"autopublish":{"type":"boolean","default":false,"description":"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":{"type":"string","enum":["dashboard","slack","mcp","api"],"default":"api","description":"Caller self-identification for attribution. Does not affect behaviour."},"client_id":{"type":"string","description":"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":{"200":{"description":"SSE stream of DraftEvent messages. The final `complete` event carries the full draft and a review_token to pass to /confirm.","content":{"text/event-stream":{"schema":{"type":"string","description":"Server-Sent Events. See operation description for the DraftEvent shape.","example":"data: {\"type\":\"meta\",\"review_token\":\"jdr_ABC...\",\"draft_id\":\"...\"}\n\ndata: {\"type\":\"field_start\",\"field\":\"title\"}\n\ndata: {\"type\":\"token\",\"field\":\"title\",\"text\":\"Senior\"}\n\n...\n\ndata: [DONE]\n\n"}}}},"422":{"description":"Missing or invalid description, or client_id does not match a client in this workspace."}}}},"/jobs/from-description/{review_token}/confirm":{"post":{"summary":"Confirm a staged draft and create the job (async)","operationId":"confirmJobDraft","tags":["Jobs"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"},"description":"The review_token emitted in the `meta` and `complete` SSE events from the draft endpoint."}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"draft":{"type":"object","description":"Optional edited draft to persist instead of the one originally streamed.","properties":{"title":{"type":"string"},"summary":{"type":"string"},"responsibilities":{"type":"array","items":{"type":"string"}},"requirements":{"type":"array","items":{"type":"string"}},"nice_to_haves":{"type":"array","items":{"type":"string"}},"benefits":{"type":"array","items":{"type":"string"},"description":"Candidate-facing benefits (medical, leave, perks). Drawn only from the recruiter's description or the client's profile - never the agency's own benefits."},"location":{"type":"string","nullable":true,"description":"Denormalised display string (\"Sydney, Australia\" or \"Remote\"). Structured siblings on the draft object are location_type, location_city, location_country, location_country_code."},"location_type":{"type":"string","enum":["In Office","Hybrid","Remote"],"nullable":true},"location_city":{"type":"string","nullable":true},"location_country":{"type":"string","nullable":true},"location_country_code":{"type":"string","nullable":true,"description":"ISO 3166-1 alpha-2"},"compensation":{"type":"object","nullable":true,"properties":{"base_min":{"type":"number","nullable":true},"base_max":{"type":"number","nullable":true},"currency":{"type":"string"},"period":{"type":"string","enum":["Per Year","Per Day","Per Hour"],"description":"How the figures are quoted on the draft. Omitted means the work type decides at confirm."}}},"employment_type":{"type":"string","enum":["full_time","part_time","contract"],"nullable":true}}},"mode":{"type":"string","enum":["draft","active"],"description":"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":{"type":"string","description":"Optional team ID to attach (tem_...)."},"hiring_manager_id":{"type":"string","description":"Optional hiring manager user ID (usr_...)."}}}}}},"responses":{"202":{"description":"Job creation queued. task_id returned in data.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Task"}}}}}},"409":{"description":"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":{"description":"Draft missing, expired, or already confirmed."}}}},"/jobs/from-description/{review_token}":{"get":{"summary":"Get the current state of a staged job draft","operationId":"getJobDraft","tags":["Jobs"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Draft state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object","nullable":true},"structured":{"type":"object"},"skipped_gaps":{"type":"array","items":{"type":"string"}},"suggested_team_id":{"type":"string","nullable":true},"gaps":{"type":"array","items":{"type":"string","enum":["client_id","title","team_id","hiring_manager_id","location","compensation","employment_type"]},"description":"client_id is the only gap confirm refuses on."},"task_status":{"type":"string"},"lint":{"type":"object","properties":{"offenders":{"type":"array","items":{"type":"string"}}}}}}}}}}},"404":{"description":"Draft not found."}}},"patch":{"summary":"Update a staged job draft (fill gaps)","operationId":"updateJobDraft","tags":["Jobs"],"description":"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).","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"updates":{"type":"object","properties":{"title":{"type":"string"},"client_id":{"type":"string","description":"Client (cli_ id) the job is for - the one mandatory gap. Must belong to the account."},"client_name":{"type":"string","description":"Optional display name override for the client."},"team_id":{"type":"string"},"team_name":{"type":"string"},"hiring_manager_id":{"type":"string"},"hiring_manager_name":{"type":"string"},"location":{"type":"object","properties":{"display":{"type":"string"},"type":{"type":"string","enum":["on_site","hybrid","remote"]},"city":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"country_code":{"type":"string","nullable":true}}},"compensation":{"type":"object","properties":{"base_min":{"type":"number","nullable":true},"base_max":{"type":"number","nullable":true},"currency":{"type":"string"},"period":{"type":"string","enum":["Per Year","Per Day","Per Hour"],"description":"How the figures are quoted. Omit and the work type decides when the draft is confirmed."}}},"employment_type":{"type":"string","enum":["full_time","part_time","contract"]}}},"skip":{"type":"array","items":{"type":"string"},"description":"Gap fields to mark as skipped."}}}}}},"responses":{"200":{"description":"Updated draft + remaining gaps","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"422":{"description":"Draft missing, expired, or already confirmed."}}}},"/jobs/from-description/{review_token}/revise":{"post":{"summary":"Apply a free-text revision to a staged draft","operationId":"reviseJobDraft","tags":["Jobs"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["instruction"],"properties":{"instruction":{"type":"string","description":"The change the user wants, in their own words."}}}}}},"responses":{"200":{"description":"Updated draft","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object"},"structured":{"type":"object"},"gaps":{"type":"array","items":{"type":"string"}}}}}}}}},"422":{"description":"Invalid instruction or draft in a non-revisable state."}}}},"/jobs/{id}/ad":{"get":{"summary":"Get the latest job ad","operationId":"getJobAd","tags":["Jobs"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Job ad","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"id":{"type":"string"},"job_id":{"type":"string"},"title":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"status":{"type":"string"},"published":{"type":"boolean"},"published_at":{"type":"string","nullable":true}}}}}}}},"404":{"description":"No ad exists for this job."}}},"patch":{"summary":"Update a job ad","operationId":"updateJobAd","tags":["Jobs"],"description":"Edit a job ad's title, description, or published state. Accepts any subset of the three fields.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"published":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated ad","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"No ad exists for this job."}}}},"/jobs/{id}/ad/rewrite":{"post":{"summary":"AI-rewrite a job ad","operationId":"rewriteJobAd","tags":["Jobs"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["instruction"],"properties":{"instruction":{"type":"string"}}}}}},"responses":{"200":{"description":"Rewritten ad","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"job_ad_id":{"type":"string"},"description":{"type":"string"}}}}}}}},"404":{"description":"No ad exists for this job."}}}},"/jobs/{id}/social-draft":{"get":{"summary":"Get a job's LinkedIn + X post drafts","operationId":"getSocialDraft","tags":["Jobs"],"description":"Fetch the auto-generated LinkedIn and X (Twitter) social post drafts for a job, plus the OG image URL used for unfurl previews.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Social draft","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"job_id":{"type":"string"},"linkedin":{"type":"string","nullable":true},"twitter":{"type":"string","nullable":true},"og_image_url":{"type":"string","nullable":true}}}}}}}},"404":{"description":"Job not found."}}},"patch":{"summary":"Update a job's social post text","operationId":"updateSocialDraft","tags":["Jobs"],"description":"Edit the LinkedIn and/or X post text. og_image_url is preserved automatically.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"linkedin":{"type":"string"},"twitter":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated social draft","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Job not found."}}}},"/jobs/{id}":{"get":{"summary":"Get job","operationId":"getJob","tags":["Jobs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Job details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Job"}}}}}}}},"patch":{"summary":"Update job","operationId":"updateJob","tags":["Jobs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"status":{"type":"string","enum":["active","on_hold","closed","filled"]},"location":{"type":"object","description":"Structured location. `type` is one of `on_site`, `hybrid`, `remote`. For remote roles, city/country/country_code are ignored.","properties":{"type":{"type":"string","enum":["on_site","hybrid","remote"]},"city":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"country_code":{"type":"string","nullable":true,"description":"ISO 3166-1 alpha-2"}}},"employment_type":{"type":"string","enum":["full_time","part_time","contract"]},"compensation":{"type":"object","properties":{"base_min":{"type":"number"},"base_max":{"type":"number"},"currency":{"type":"string"},"period":{"type":"string","enum":["year","day","hour"],"description":"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":{"type":"string","nullable":true,"description":"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":{"type":"string","nullable":true,"description":"Contact at the client (cct_ id). Must belong to the job's client."},"team_id":{"type":"string","nullable":true},"hiring_manager_id":{"type":"string","nullable":true},"default_interview_format":{"type":"string","enum":["video","phone","in_person","technical"],"description":"Default interview format used when auto-booking interviews on stage transition."},"is_evergreen":{"type":"boolean","description":"Mark or unmark the job as an always-open pipeline job (excluded from performance metrics)."}}}}}},"responses":{"200":{"description":"Updated job","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Job"}}}}}}}},"delete":{"summary":"Delete job","operationId":"deleteJob","tags":["Jobs"],"description":"Soft deletes a job. The record is hidden from all queries but not permanently removed. Requires jobs:write scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}},"404":{"description":"Job not found"}}}},"/jobs/{id}/close":{"post":{"summary":"Close job","operationId":"closeJob","tags":["Jobs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Job closed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Job"}}}}}}}}},"/jobs/{id}/reopen":{"post":{"summary":"Reopen job","operationId":"reopenJob","tags":["Jobs"],"description":"Reopen a closed or filled job. Sets status to active. Requires jobs:write.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Job reopened","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["active"]},"reopened_at":{"type":"string","format":"date-time"}}}}}}}},"409":{"description":"Job is already active."},"422":{"description":"Plan job limit reached."}}}},"/jobs/{id}/summary":{"get":{"summary":"AI job summary","operationId":"getJobSummary","tags":["Jobs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"AI-generated role summary","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}}},"/jobs/{id}/ranking":{"get":{"summary":"Stack-ranked interviewed candidates for a job","operationId":"getJobRanking","tags":["Jobs"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Ranked candidate list","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"job_id":{"type":"string","example":"job_550e8400e29b41d4a716446655440000"},"rankings":{"type":"array","items":{"type":"object","properties":{"candidate":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","nullable":true},"email":{"type":"string","nullable":true}}},"application_id":{"type":"string"},"aggregate_score":{"type":"number","minimum":0,"maximum":100,"description":"Mean of form_submissions.score for this application."},"interview_count":{"type":"integer","description":"Number of scored form submissions."},"latest_stage":{"type":"string","enum":["funnel","maybe","yes","no","screen","submitted","client_interview","references","offer","placed","rejected","withdrew"]},"per_criterion":{"type":"array","items":{"type":"object","properties":{"criterion_name":{"type":"string"},"score_0_100":{"type":"number","minimum":0,"maximum":100},"reasoning":{"type":"string"}}}},"recommendation":{"type":"string","nullable":true,"enum":["strong_hire","hire","hold","talent_pool","reject",null]},"last_interviewed_at":{"type":"string","format":"date-time","nullable":true}}}},"generated_at":{"type":"string","format":"date-time"}}}}}}}},"404":{"description":"Job not found"}}}},"/jobs/{id}/share-bundle":{"get":{"summary":"Get share-ready content bundle for a job","operationId":"getJobShareBundle","tags":["Jobs"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share bundle","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"x_text":{"type":"string","description":"Pre-composed X / Twitter post copy, under 260 chars."},"linkedin_text":{"type":"string","description":"Pre-composed LinkedIn post copy, 300-400 chars."},"careers_url":{"type":"string","format":"uri","description":"Public careers page URL for the role."},"image_url":{"type":"string","format":"uri","nullable":true,"description":"The agency's share card (OG image) to attach when sharing - reflects the design set in Settings > Brand. Agency-branded, never the client."},"email_summary":{"type":"string","description":"Two-sentence summary for email fallbacks."},"source":{"type":"object","properties":{"brand_voice_applied":{"type":"boolean"},"generated_at":{"type":"string","format":"date-time"},"cached":{"type":"boolean","description":"True when returned from the persisted social_draft; false when freshly generated on this call."}}}}}}}}}},"404":{"description":"Job not found"}}}},"/job-ads":{"get":{"summary":"List job ads","operationId":"listJobAds","tags":["Job Ads"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"job_id","in":"query","schema":{"type":"string"},"description":"Filter to one job (job_ id)."},{"name":"status","in":"query","schema":{"type":"string","enum":["publishing","live","expired"]},"description":"Filter by ad status. Any other value is a 422, never silently ignored."}],"responses":{"200":{"description":"Paginated job ads","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}},"422":{"description":"Invalid status filter."}}},"post":{"summary":"Create job ad","operationId":"createJobAd","tags":["Job Ads"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["job_id"],"properties":{"job_id":{"type":"string","description":"External ID of the job this ad advertises."},"channels":{"type":"array","items":{"type":"string","enum":["careers_page","jobs_widget","referio","linkedin","x","amplifiers"]},"description":"Distribution channels. Include \"referio\" to post to the referral marketplace (requires a referral_fee)."},"content":{"type":"object","properties":{"headline":{"type":"string"},"body":{"type":"string"}}},"referral_fee":{"type":"number","description":"Fee paid for a successful Referio referral. Required when posting to the referio channel; Referio enforces a US$5,000 minimum."},"referral_fee_currency":{"type":"string","description":"ISO 4217 currency for referral_fee. Defaults to the ad currency."},"expires_at":{"type":"string","format":"date-time","nullable":true}}}}}},"responses":{"202":{"description":"Job ad queued for generation and publishing. Poll data.poll_url; data.resource_id is the job_ad id.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Task"}}}}}}}}},"/job-ads/{id}":{"get":{"summary":"Get job ad","operationId":"getJobAd","tags":["Job Ads"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Job ad details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}},"delete":{"summary":"Delete job ad","operationId":"deleteJobAd","tags":["Job Ads"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}},"404":{"description":"Job ad not found"}}}},"/job-ads/{id}/expire":{"post":{"summary":"Expire job ad","operationId":"expireJobAd","tags":["Job Ads"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Expired","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}}},"/clients":{"get":{"summary":"List clients","operationId":"listClients","tags":["Clients"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"search","in":"query","description":"Case-insensitive substring match on the client name.","schema":{"type":"string"}},{"name":"domain","in":"query","description":"Exact match on the normalised bare domain.","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated list of clients","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Client"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create client","operationId":"createClient","tags":["Clients"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Required if website is omitted."},"website":{"type":"string","description":"Providing this kicks off enrichment + the Client DNA first pass."},"industry":{"type":"string"},"notes":{"type":"string"},"allow_duplicate":{"type":"boolean","description":"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":{"type":"object","description":"Initial agreed terms (importing your book) - becomes the client's fee schedule v1. Later changes go through the set_client_fee_terms action.","properties":{"standard_fee_percent":{"type":"number","description":"A flat percent - becomes a single fee band."},"payment_terms_days":{"type":"integer"},"guarantee_days":{"type":"integer"}}}}}}}},"responses":{"201":{"description":"Created client (enrichment_status \"pending\" when a website was given)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Client"}}}}}}}}},"/clients/{id}":{"get":{"summary":"Get client","operationId":"getClient","tags":["Clients"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Client details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Client"}}}}}},"404":{"description":"Client not found"}}},"patch":{"summary":"Update client","operationId":"updateClient","tags":["Clients"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"website":{"type":"string","description":"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":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"locations":{"type":"array","items":{"type":"string"}},"address":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"logo_url":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated client","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Client"}}}}}},"409":{"description":"The agency self-client cannot be edited through the API"}}},"delete":{"summary":"Delete client","operationId":"deleteClient","tags":["Clients"],"description":"Soft deletes a client. Jobs keep running; their client_id clears only if the row is hard-deleted (never over this API).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}},"404":{"description":"Client not found"}}}},"/clients/{id}/contacts":{"get":{"summary":"List client contacts","operationId":"listClientContacts","tags":["Clients"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Contacts at this client, primary first","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ClientContact"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Add client contact","operationId":"addClientContact","tags":["Clients"],"description":"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).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string","description":"Digits with an optional leading + country code; spaces, hyphens, dots and parentheses allowed. Rejected with a validation error otherwise."},"title":{"type":"string"},"reports_to":{"type":"string","description":"Another contact (cct_ id) on this client this person reports to. Must belong to the same client."},"is_primary":{"type":"boolean"},"notes":{"type":"string"}}}}}},"responses":{"201":{"description":"Created contact","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ClientContact"}}}}}},"422":{"description":"Validation error - e.g. phone is not a valid phone number"}}}},"/clients/{id}/contacts/{contactId}":{"patch":{"summary":"Update client contact","operationId":"updateClientContact","tags":["Clients"],"description":"Patching name renames the person, not just this contact - the new name shows everywhere that person appears.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"contactId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true,"description":"Digits with an optional leading + country code; spaces, hyphens, dots and parentheses allowed. Rejected with a validation error otherwise."},"title":{"type":"string","nullable":true},"reports_to":{"type":"string","nullable":true,"description":"Another contact (cct_ id) on the same client this person reports to. Send null to clear. Cannot be the contact itself."},"is_primary":{"type":"boolean"},"notes":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated contact","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ClientContact"}}}}}},"422":{"description":"Validation error - e.g. phone is not a valid phone number"}}},"delete":{"summary":"Delete client contact","operationId":"deleteClientContact","tags":["Clients"],"description":"Soft deletes a contact. Any job pointing at it keeps the reference cleared by the composite FK rules on the job side.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"contactId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}},"404":{"description":"Contact not found"}}}},"/submissions":{"get":{"summary":"List submissions","operationId":"listSubmissions","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"job_id","in":"query","schema":{"type":"string"},"description":"Filter to one job (job_ id)."},{"name":"client_id","in":"query","schema":{"type":"string"},"description":"Filter to one client (cli_ id)."},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","viewed","partially_responded","completed","expired","revoked"]}}],"responses":{"200":{"description":"Paginated list of submissions, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Submission"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create and send a submission","operationId":"createSubmission","tags":["Submissions"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["job_id","items"],"properties":{"job_id":{"type":"string"},"items":{"type":"array","items":{"type":"object","required":["application_id"],"properties":{"application_id":{"type":"string"},"summary":{"type":"string","description":"Client-facing pitch. Omit to have AI draft it."}}}},"client_contact_id":{"type":"string","description":"Must belong to the job's client. Defaults to the job's contact, then the primary contact."},"title":{"type":"string"},"greeting":{"type":"string","description":"Opening line of the email, no trailing comma (e.g. \"Hi Brett\"). Defaults to \"Hi {first name}\"."},"email_subject":{"type":"string","description":"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":{"type":"string","description":"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":{"type":"string","description":"Legacy covering note. It renders under the DEFAULT intro and is ignored when intro_body is set. Use intro_body instead."},"allow_resubmit":{"type":"boolean","default":false,"description":"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":{"type":"string","enum":["full","first_name","anonymous"],"default":"full","description":"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":{"201":{"description":"Created submission. email_sent false means the link works but the email failed - resend from the product.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"allOf":[{"$ref":"#/components/schemas/Submission"},{"type":"object","properties":{"email_sent":{"type":"boolean"}}}]}}}}}}}}},"/submissions/{id}":{"get":{"summary":"Get submission","operationId":"getSubmission","tags":["Submissions"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Submission with per-candidate response state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Submission"}}}}}},"404":{"description":"Submission not found"}}}},"/specs":{"get":{"summary":"List specs","operationId":"listSpecs","tags":["Specs"],"description":"Specs (Floats in AU/NZ): speculative submissions - one anonymous candidate profile sent to one client contact with no job. Requires submissions:read.","parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"candidate_id","in":"query","schema":{"type":"string"},"description":"Filter to one candidate (cnd_ id)."},{"name":"client_id","in":"query","schema":{"type":"string"},"description":"Filter to one client (cli_ id)."},{"name":"status","in":"query","schema":{"type":"string","enum":["sent","viewed","interested","passed","converted","expired","revoked"]}}],"responses":{"200":{"description":"Paginated list of specs, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Spec"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create and send a spec","operationId":"createSpec","tags":["Specs"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["candidate_id","client_id","client_contact_id","headline","pitch","email_subject","email_body"],"properties":{"candidate_id":{"type":"string"},"client_id":{"type":"string"},"client_contact_id":{"type":"string","description":"Must belong to the client."},"headline":{"type":"string","description":"Anonymous profile title, e.g. \"Senior Quantity Surveyor - Tier 1 commercial\"."},"pitch":{"type":"string","description":"Why this person is worth meeting. Employers generalised, never named."},"chips":{"type":"array","items":{"type":"string"},"description":"Up to 6 short facts (years, sector, location)."},"why_client":{"type":"string","description":"Why this candidate fits THIS client - shown on their private page."},"email_subject":{"type":"string"},"email_greeting":{"type":"string","description":"Opening line of the email, no trailing comma (e.g. \"Hi Brett\"). Defaults to \"Hi {first name}\"."},"email_body":{"type":"string","description":"Short personal note. The profile button and signature are added automatically."},"attach_terms":{"type":"boolean","default":true},"candidate_aware":{"type":"boolean","default":false,"description":"The honest record: does the candidate know?"}}}}}},"responses":{"201":{"description":"Created spec. email_sent false means the profile link works but the email failed - share it directly.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"allOf":[{"$ref":"#/components/schemas/Spec"},{"type":"object","properties":{"email_sent":{"type":"boolean"}}}]}}}}}}}}},"/specs/{id}":{"get":{"summary":"Get spec","operationId":"getSpec","tags":["Specs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Spec with its response state and conversion links","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Spec"}}}}}},"404":{"description":"Spec not found"}}}},"/candidates":{"get":{"summary":"List candidates","operationId":"listCandidates","tags":["Candidates"],"description":"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.","parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"email","in":"query","description":"Exact (case-insensitive) email lookup.","schema":{"type":"string"}},{"name":"tag","in":"query","schema":{"type":"string"}},{"name":"location_city","in":"query","description":"Case-insensitive substring match on location.","schema":{"type":"string"}},{"name":"do_not_approach","in":"query","description":"Filter by the off-limits flag. Omit to list all candidates - the list includes flagged candidates by default.","schema":{"type":"boolean"}},{"name":"contact_only","in":"query","description":"Role filter. true = only people who exist purely as client contacts; false = only the candidate pool. Omit to list everyone.","schema":{"type":"boolean"}},{"name":"rating","in":"query","description":"Exact star rating 1-5.","schema":{"type":"integer","minimum":1,"maximum":5}},{"name":"min_rating","in":"query","description":"Minimum star rating 1-5 (\"4 and up\" = 4). Unrated candidates never match.","schema":{"type":"integer","minimum":1,"maximum":5}},{"$ref":"#/components/parameters/createdAfter"},{"$ref":"#/components/parameters/createdBefore"}],"responses":{"200":{"description":"Paginated candidates","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Candidate"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create candidate","operationId":"createCandidate","tags":["Candidates"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"name":{"type":"string","description":"Full name. Alternatively pass first_name + last_name."},"first_name":{"type":"string"},"last_name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string","nullable":true},"linkedin_url":{"type":"string","nullable":true},"location":{"type":"object","properties":{"city":{"type":"string"}}},"skills":{"type":"array","items":{"type":"string"}},"resume_url":{"type":"string","nullable":true,"description":"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":{"type":"integer","nullable":true,"minimum":1,"maximum":5,"description":"The agency's 1-5 star rating."},"tags":{"type":"array","items":{"type":"string"},"description":"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":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Roles the candidate wants next."},"preferred_seniority":{"type":"string","nullable":true,"enum":["intern","junior","mid","senior","staff","lead","principal","head","director","vp","c_suite"]},"work_types":{"type":"array","items":{"type":"string","enum":["permanent","contract","temp","part_time"]},"nullable":true},"open_to_relocation":{"type":"boolean","nullable":true},"available_from":{"type":"string","format":"date","nullable":true},"preferred_locations":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Places the candidate wants to work; gazetteer-resolved asynchronously."}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Candidate"}}}}}},"403":{"description":"QUOTA_EXCEEDED - a resume_url was sent and the account has used its 5,000-CV rolling 30-day allowance."},"409":{"description":"A live candidate with this email already exists in this company (emails are unique case-insensitively)."}}}},"/candidates/{id}":{"get":{"summary":"Get candidate","operationId":"getCandidate","tags":["Candidates"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Candidate details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Candidate"}}}}}}}},"patch":{"summary":"Update candidate","operationId":"updateCandidate","tags":["Candidates"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"first_name":{"type":"string"},"last_name":{"type":"string"},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"linkedin_url":{"type":"string","nullable":true},"location":{"type":"object","properties":{"city":{"type":"string"}}},"skills":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"},"description":"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":{"type":"string","nullable":true,"description":"Also accepted as current_job_title."},"current_company":{"type":"string","nullable":true,"description":"Also accepted as current_employer."},"do_not_approach":{"type":"boolean","description":"Off-limits flag. Flagged candidates are excluded from search, matching and suggestions."},"open_to_work":{"type":"boolean","nullable":true,"description":"Open-to-work flag. Null clears it back to never-captured."},"rating":{"type":"integer","nullable":true,"minimum":1,"maximum":5,"description":"The agency's 1-5 star rating. Null clears it."},"salary_expectation":{"type":"string","nullable":true,"description":"Raw salary expectation (\"145k + super\", \"850/day\"). The normalized annual figure, amount, period, and currency derive automatically."},"notice_period":{"type":"string","nullable":true,"description":"Raw notice period (\"4 weeks\"). notice_period_days derives automatically."},"work_rights":{"type":"string","nullable":true,"description":"Raw right-to-work statement (\"citizen\", \"482 visa\"). work_rights_status derives automatically."},"preferred_roles":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Roles the candidate wants next. Null clears."},"preferred_seniority":{"type":"string","nullable":true,"enum":["intern","junior","mid","senior","staff","lead","principal","head","director","vp","c_suite"],"description":"Seniority level the candidate wants. Free text like \"Head of\" normalizes automatically."},"work_types":{"type":"array","items":{"type":"string","enum":["permanent","contract","temp","part_time"]},"nullable":true,"description":"Work types the candidate is open to. Variants like \"perm\" or \"full-time\" normalize automatically."},"open_to_relocation":{"type":"boolean","nullable":true},"available_from":{"type":"string","format":"date","nullable":true,"description":"Stated availability date (YYYY-MM-DD). Null clears."},"preferred_locations":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Places the candidate wants to work. Replaces the FULL set on every write; each entry is gazetteer-resolved asynchronously for search."}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Candidate"}}}}}}}},"delete":{"summary":"Delete candidate","operationId":"deleteCandidate","tags":["Candidates"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}},"404":{"description":"Candidate not found"}}}},"/candidates/{id}/gdpr-delete":{"delete":{"summary":"GDPR delete candidate","operationId":"gdprDeleteCandidate","tags":["Candidates"],"description":"Permanently deletes all candidate data including applications, interviews, assessments, and stored CVs. Irreversible.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}}}}},"/candidates/search":{"post":{"summary":"Search candidates","operationId":"searchCandidates","tags":["Candidates"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["query"],"properties":{"query":{"type":"string","description":"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\").","example":"senior project managers in Melbourne with SAP"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Results per page."},"page":{"type":"integer","minimum":1,"default":1,"description":"1-based page into the ranked pool. The ranking is deterministic, so page N is always the same slice for the same query."}}}}}},"responses":{"200":{"description":"Ranked matches","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/CandidateSearchResult"},"meta":{"$ref":"#/components/schemas/Meta"}}}}}},"422":{"description":"query is required, or limit/page out of range."},"500":{"description":"The search compiler is temporarily unavailable. The API never degrades to a different matcher - retry."}}}},"/candidate-imports":{"post":{"summary":"Start a bulk CV import","operationId":"createCandidateImport","tags":["Candidate Imports"],"description":"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.\n\nLimits: 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.\n\nOn 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["file_count"],"properties":{"file_count":{"type":"integer","minimum":1,"maximum":5000,"description":"How many files this import will send."},"match_pools":{"type":"boolean","default":true,"description":"Match imported candidates against talent pools with AI matching on as they land. Defaults to true; pass false to import without pool matching."}}}}}},"responses":{"201":{"description":"Import created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"id":{"type":"string","example":"imp_550e8400e29b41d4a716446655440000"},"status":{"type":"string","example":"uploading"},"file_count":{"type":"integer"},"match_pools":{"type":"boolean"}}}}}}}},"403":{"description":"QUOTA_EXCEEDED - this account has used its 5,000-file rolling 30-day import allowance"},"413":{"description":"PAYLOAD_TOO_LARGE - file_count is above 5,000"}}}},"/candidate-imports/{id}":{"get":{"summary":"Get import report","operationId":"getCandidateImport","tags":["Candidate Imports"],"description":"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).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Import report","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/CandidateImport"}}}}}},"404":{"description":"Import not found"}}}},"/candidate-imports/{id}/files":{"post":{"summary":"Request upload slots","operationId":"addCandidateImportFiles","tags":["Candidate Imports"],"description":"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).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"files":{"type":"array","maxItems":25,"items":{"type":"object","required":["filename"],"properties":{"filename":{"type":"string","example":"jane-doe-cv.pdf"}}}}}}}}},"responses":{"201":{"description":"Upload slots","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"imi_550e8400e29b41d4a716446655440000"},"filename":{"type":"string"},"index":{"type":"integer","description":"Position in the request files array - match slots to files by this, not by filename."},"upload_url":{"type":"string","description":"Signed URL. PUT the raw file body here."},"max_file_bytes":{"type":"integer"}}}}}}}}}}},"403":{"description":"QUOTA_EXCEEDED - this account has used its 5,000-file rolling 30-day import allowance"},"409":{"description":"Import is no longer accepting files, or file_count would be exceeded"}}}},"/candidate-imports/{id}/finalize":{"post":{"summary":"Confirm uploads","operationId":"finalizeCandidateImport","tags":["Candidate Imports"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"uploaded_item_ids":{"type":"array","items":{"type":"string"}},"complete":{"type":"boolean","description":"Set true on the final call to close the upload phase."}}}}}},"responses":{"200":{"description":"Confirmed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"confirmed":{"type":"integer"},"status":{"type":"string"}}}}}}}}}}},"/search":{"post":{"summary":"Natural language search","operationId":"nlSearch","tags":["Search"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["query","entity_type"],"properties":{"query":{"type":"string","description":"Natural language search query","example":"Show me candidates in London with sales experience"},"entity_type":{"type":"string","enum":["candidates","applications","jobs","interviews"],"description":"The type of entity to search"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":25,"description":"Max results per page"},"page":{"type":"integer","minimum":1,"default":1,"description":"Page number"},"sort_by":{"type":"string","description":"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":{"type":"string","enum":["asc","desc"],"default":"desc","description":"Not accepted for entity_type=candidates, as above."}}}}}},"responses":{"200":{"description":"Search results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"oneOf":[{"$ref":"#/components/schemas/CandidateSearchResult"},{"$ref":"#/components/schemas/EntitySearchResult"}],"discriminator":{"propertyName":"entity_type"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}},"422":{"description":"query or entity_type missing or invalid, or sort_by/sort_order passed with entity_type=candidates."},"500":{"description":"The search compiler is temporarily unavailable (candidates only). The API never degrades to a different matcher - retry."}}}},"/candidates/duplicates":{"get":{"summary":"Find duplicate candidates","operationId":"findDuplicates","tags":["Candidates"],"responses":{"200":{"description":"Duplicate pairs","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object"}}}}}}}}}},"/candidates/merge":{"post":{"summary":"Merge candidates","operationId":"mergeCandidates","tags":["Candidates"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["keep_id","merge_id"],"properties":{"keep_id":{"type":"string"},"merge_id":{"type":"string"}}}}}},"responses":{"200":{"description":"Merged","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Candidate"}}}}}}}}},"/applications":{"get":{"summary":"List applications","operationId":"listApplications","tags":["Applications"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"job_id","in":"query","schema":{"type":"string"}},{"name":"candidate_id","in":"query","schema":{"type":"string"}},{"name":"stage","in":"query","schema":{"type":"string"}},{"name":"source","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/createdAfter"},{"$ref":"#/components/parameters/createdBefore"}],"responses":{"200":{"description":"Paginated applications with assessment scores","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Application"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create application (async)","operationId":"createApplication","tags":["Applications"],"description":"Attaches an existing candidate to a job and triggers AI assessment. Returns 202.\n\nSource 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).","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["job_id","candidate_id"],"properties":{"job_id":{"type":"string","description":"External job id (job_...)."},"candidate_id":{"type":"string","description":"External candidate id (cnd_...)."},"source":{"type":"string","nullable":true,"description":"Source slug (linkedin, indeed, job_ad, referral, careers_page, ...). Mapped to a category + detail. Ignored when source_category is supplied."},"source_category":{"type":"string","nullable":true,"enum":["Careers Page","Job Board","Referral","Talent Pool","Database Search","Social","Agency","Direct","Imported","Other"],"description":"Canonical source category."},"source_detail":{"type":"string","nullable":true,"description":"Granular sub-source (exact board, referrer, pool, or search)."},"cover_note":{"type":"string","nullable":true}}}}}},"responses":{"202":{"description":"Application queued for assessment","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Task"}}}}}}}}},"/applications/{id}":{"get":{"summary":"Get application","operationId":"getApplication","tags":["Applications"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Application with assessment","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Application"}}}}}}}},"patch":{"summary":"Update application","operationId":"updateApplication","tags":["Applications"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"note":{"type":"string","description":"Free-text note to store against the application."}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Application"}}}}}},"410":{"description":"Gone - stage/status field rejected. Use POST /applications/{id}/reject or POST /actions instead."}}}},"/applications/bulk-update":{"post":{"summary":"Bulk update applications","operationId":"bulkUpdateApplications","tags":["Applications"],"responses":{"200":{"description":"Bulk update results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"updated":{"type":"integer"},"failed":{"type":"integer"}}}}}}}}}}},"/interviews":{"get":{"summary":"List interviews","operationId":"listInterviews","tags":["Interviews"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"$ref":"#/components/parameters/createdAfter"},{"$ref":"#/components/parameters/createdBefore"}],"responses":{"200":{"description":"Paginated interviews","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Interview"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Schedule interview","operationId":"createInterview","tags":["Interviews"],"description":"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).","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["application_id"],"properties":{"application_id":{"type":"string"},"scheduled_at":{"type":"string","format":"date-time"},"format":{"type":"string","enum":["phone","video","in_person","technical"]},"flavour":{"type":"string","enum":["internal","client"],"default":"internal","description":"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":{"type":"string","description":"Link a client interview to the submission it came from (sub_ id)."},"client_contact_ids":{"type":"array","maxItems":3,"items":{"type":"string"},"description":"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":{"type":"integer"},"duration_minutes":{"type":"integer"},"video_link":{"type":"string","description":"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":{"type":"string","description":"Where an in-person interview happens. Rides the calendar invite's LOCATION line and the invitation emails."},"scheduling_mode":{"type":"string","enum":["propose","automated","self_schedule"]},"interviewer_ids":{"type":"array","items":{"type":"string"},"description":"Agency-side attendees (usr_ ids). Internal interviews only - a client interview is the client meeting the candidate and carries none."},"notes_for_candidate":{"type":"string"},"notes_for_interviewer":{"type":"string"},"ignore_conflicts":{"type":"boolean","default":false,"description":"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":{"201":{"description":"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).","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Interview"},"meta":{"type":"object","properties":{"warnings":{"type":"array","items":{"type":"string"}}}}}}}}},"409":{"description":"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.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"data":{"type":"object","properties":{"conflicts":{"type":"array","items":{"type":"object","properties":{"attendee_email":{"type":"string"},"attendee_name":{"type":"string","nullable":true},"busy_starts_at":{"type":"string","format":"date-time"},"busy_ends_at":{"type":"string","format":"date-time"},"busy_title":{"type":"string","nullable":true}}}},"suggestions":{"type":"array","items":{"type":"object","properties":{"starts_at":{"type":"string","format":"date-time"},"ends_at":{"type":"string","format":"date-time"}}}},"warnings":{"type":"array","items":{"type":"string"}}}},"error":{"$ref":"#/components/schemas/Error"}}}}}}}}},"/interviews/{id}":{"get":{"summary":"Get interview","operationId":"getInterview","tags":["Interviews"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Interview details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Interview"}}}}}}}},"patch":{"summary":"Update interview","operationId":"updateInterview","tags":["Interviews"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"outcome":{"type":"string","enum":["pending","passed","failed","no_show","cancelled"],"description":"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":{"type":"string","enum":["scheduled","completed","cancelled","no_show"],"description":"Legacy coarse status; completed records a pass. Use outcome to record a fail."},"format":{"type":"string","enum":["phone","video","in_person","technical"]},"scheduled_at":{"type":"string","format":"date-time","description":"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":{"type":"boolean","default":false,"description":"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":{"type":"string"},"duration_minutes":{"type":"integer"},"round":{"type":"integer"},"notes_for_candidate":{"type":"string"},"notes_for_interviewer":{"type":"string"},"feedback":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Interview"}}}}}},"409":{"description":"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.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"data":{"type":"object","properties":{"conflicts":{"type":"array","items":{"type":"object","properties":{"attendee_email":{"type":"string"},"attendee_name":{"type":"string","nullable":true},"busy_starts_at":{"type":"string","format":"date-time"},"busy_ends_at":{"type":"string","format":"date-time"},"busy_title":{"type":"string","nullable":true}}}},"suggestions":{"type":"array","items":{"type":"object","properties":{"starts_at":{"type":"string","format":"date-time"},"ends_at":{"type":"string","format":"date-time"}}}},"warnings":{"type":"array","items":{"type":"string"}}}},"error":{"$ref":"#/components/schemas/Error"}}}}}}}},"delete":{"summary":"Delete interview","operationId":"deleteInterview","tags":["Interviews"],"description":"Soft deletes an interview. The record is hidden from all queries but not permanently removed. Requires interviews:write scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}},"404":{"description":"Interview not found"}}}},"/interviews/{id}/cancel":{"post":{"summary":"Cancel interview","operationId":"cancelInterview","tags":["Interviews"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Interview"}}}}}}}}},"/interviews/{id}/scorecard":{"post":{"summary":"Submit interview scorecard","operationId":"submitScorecard","tags":["Interviews"],"description":"Submit a structured scorecard (per-criterion scores + overall recommendation + notes) for an interview. Persists the scorecard on the interview record. Requires interviews:write.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recommendation"],"properties":{"scores":{"type":"object","additionalProperties":{"type":"number"},"description":"Map of criterion name to numeric score (e.g. { \"Technical\": 4 })."},"recommendation":{"type":"string","enum":["strong_hire","hire","no_hire","strong_no_hire"]},"notes":{"type":"string","description":"Free-text scorecard notes."}}}}}},"responses":{"200":{"description":"Scorecard stored","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Interview"}}}}}},"422":{"description":"Missing or invalid recommendation."}}}},"/interviews/{id}/schedule-link":{"post":{"summary":"Generate candidate self-schedule link","operationId":"generateInterviewScheduleLink","tags":["Interviews"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"send_email":{"type":"boolean","default":true,"description":"Email the candidate the link automatically."},"ttl_days":{"type":"integer","minimum":1,"maximum":30,"default":7,"description":"Link validity window in days."}}}}}},"responses":{"201":{"description":"Schedule link created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"token":{"type":"string","description":"Opaque token (also included in the url). Treat as a secret."},"url":{"type":"string","format":"uri","description":"Public URL the candidate opens."},"expires_at":{"type":"string","format":"date-time"},"email_sent":{"type":"boolean"}}}}}}}},"409":{"description":"Interview is already scheduled or cancelled."}}}},"/placements":{"get":{"summary":"List placements","operationId":"listPlacements","tags":["Placements"],"parameters":[{"name":"client_id","in":"query","schema":{"type":"string"},"description":"Filter by client (cli_)."},{"name":"candidate_id","in":"query","schema":{"type":"string"},"description":"Filter by candidate (cnd_)."},{"name":"job_id","in":"query","schema":{"type":"string"},"description":"Filter by job (job_)."},{"name":"status","in":"query","schema":{"type":"string","enum":["pending_start","started","fell_off","completed"]}},{"$ref":"#/components/parameters/createdAfter"},{"$ref":"#/components/parameters/createdBefore"}],"responses":{"200":{"description":"Paginated placements","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Placement"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Log placement","operationId":"createPlacement","tags":["Placements"],"description":"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.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"application_id":{"type":"string","description":"app_ id. Resolves candidate, client, and job."},"candidate_id":{"type":"string","description":"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":{"type":"string","description":"cli_ id."},"job_id":{"type":"string","description":"job_ id. Disambiguates when the candidate is on more than one of the client's jobs."},"salary":{"type":"number","description":"First-year salary."},"currency":{"type":"string","description":"ISO 4217 code. Omit to let it default (see the endpoint description)."},"fee_type":{"type":"string","enum":["percent","fixed"],"default":"percent"},"fee_percent":{"type":"number","description":"Defaults to the salary's band on the client's fee schedule (minimum fee applied)."},"fee_amount":{"type":"number","description":"Fixed fee amount (fee_type fixed)."},"placement_type":{"type":"string","enum":["permanent","contract"],"description":"Defaults from the job's employment_type: a contract job logs a contract placement."},"offer_date":{"type":"string","format":"date","description":"When the offer was accepted. Defaults to today."},"contract_end_date":{"type":"string","format":"date","description":"Contract placements: the initial term's end date."},"charge_rate":{"type":"number","description":"Contract placements: what the client pays, per rate_period."},"pay_rate":{"type":"number","description":"Contract placements: what the contractor gets, per rate_period."},"rate_period":{"type":"string","enum":["hourly","daily","weekly"]},"expected_gp":{"type":"number","description":"Contract placements: expected gross profit over the initial term. Defaults to margin x term off the rates; an explicit value wins."},"credits":{"type":"array","description":"The credit split - one line per consultant, percents totalling 100. Defaults to the placement owner at 100%.","items":{"type":"object","properties":{"user_id":{"type":"string"},"percent":{"type":"number"},"role":{"type":"string","enum":["candidate_consultant","job_owner","business_development","other"]}}}},"start_date":{"type":"string","format":"date"},"guarantee_days":{"type":"integer","description":"Defaults to the client's guarantee_days."},"notes":{"type":"string"}}}}}},"responses":{"201":{"description":"Placement logged","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Placement"}}}}}}}}},"/placements/{id}":{"get":{"summary":"Get placement","operationId":"getPlacement","tags":["Placements"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Placement details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Placement"}}}}}}}},"patch":{"summary":"Update placement","operationId":"updatePlacement","tags":["Placements"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["pending_start","started","fell_off","completed"]},"salary":{"type":"number"},"currency":{"type":"string"},"fee_type":{"type":"string","enum":["percent","fixed"]},"fee_percent":{"type":"number"},"fee_amount":{"type":"number"},"placement_type":{"type":"string","enum":["permanent","contract"]},"offer_date":{"type":"string","format":"date"},"contract_end_date":{"type":"string","format":"date"},"charge_rate":{"type":"number"},"pay_rate":{"type":"number"},"rate_period":{"type":"string","enum":["hourly","daily","weekly"]},"expected_gp":{"type":"number"},"credits":{"type":"array","description":"Replaces the whole split. Percents must total 100.","items":{"type":"object","properties":{"user_id":{"type":"string"},"percent":{"type":"number"},"role":{"type":"string","enum":["candidate_consultant","job_owner","business_development","other"]}}}},"start_date":{"type":"string","format":"date"},"guarantee_days":{"type":"integer"},"notes":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Placement"}}}}}}}}},"/quotas/plan":{"get":{"summary":"Get quota plan","operationId":"getQuotaPlan","tags":["Quotas"],"description":"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.","responses":{"200":{"description":"The quota plan","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/QuotaPlan"}}}}}}}}},"/quotas/periods":{"get":{"summary":"List quota periods","operationId":"listQuotaPeriods","tags":["Quotas"],"description":"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.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":12,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Quota periods","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/QuotaPeriod"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}}},"/quotas/periods/{id}":{"get":{"summary":"Get quota period board","operationId":"getQuotaPeriodBoard","tags":["Quotas"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"qpd_ id, or `current`."}],"responses":{"200":{"description":"The period's quota board","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"allOf":[{"$ref":"#/components/schemas/QuotaPeriod"},{"type":"object","properties":{"currency":{"type":"string","description":"The plan currency targets are measured in."},"recognition_basis":{"type":"string","enum":["offer_accepted","start_date"]},"agency":{"type":"object","nullable":true,"description":"Agency-wide progress. Null for a team-scoped key.","properties":{"booked":{"$ref":"#/components/schemas/QuotaMoney"},"placements":{"type":"integer"},"target":{"type":"number","nullable":true}}},"teams":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"tem_550e8400e29b41d4a716446655440000"},"name":{"type":"string"},"booked":{"$ref":"#/components/schemas/QuotaMoney"},"placements":{"type":"integer"},"target":{"type":"number","nullable":true},"members":{"type":"array","items":{"$ref":"#/components/schemas/QuotaBoardRow"}}}}},"unassigned":{"type":"array","description":"Consultants on no team. Empty for a team-scoped key.","items":{"$ref":"#/components/schemas/QuotaBoardRow"}},"adjustments":{"type":"array","description":"The period's ledger, oldest first. auto rows balance a locked-period change and cannot be removed. Empty unless the key may see it: a key acting as a user needs that user to hold the adjustments capability, the same wall the dashboard applies.","items":{"type":"object","properties":{"user_id":{"type":"string","nullable":true},"user_name":{"type":"string","nullable":true},"placement_id":{"type":"string","nullable":true},"kind":{"type":"string","enum":["manual","auto"]},"amount":{"type":"number","description":"Signed: positive credits, negative debits."},"currency":{"type":"string"},"reason":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}}}}}]}}}}}}}}},"/marketplace/listings":{"get":{"summary":"Browse marketplace listings","operationId":"listMarketplaceListings","tags":["Marketplace"],"description":"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.","parameters":[{"name":"kind","in":"query","schema":{"type":"string","enum":["job","candidate"]}},{"name":"mine","in":"query","schema":{"type":"boolean","default":false},"description":"true = your own listings at any status."},{"name":"status","in":"query","schema":{"type":"string","enum":["live","paused","filled","expired","withdrawn"]},"description":"Only meaningful with mine=true (browse serves live only)."},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"after","in":"query","schema":{"type":"string"},"description":"Cursor from meta.next_cursor"}],"responses":{"200":{"description":"Paginated listings","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MarketplaceListing"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Share to the marketplace","operationId":"createMarketplaceListing","tags":["Marketplace"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["kind","headline","pitch"],"properties":{"kind":{"type":"string","enum":["job","candidate"]},"job_id":{"type":"string","description":"job_ id. Required for kind=job."},"candidate_id":{"type":"string","description":"cnd_ id. Required for kind=candidate."},"headline":{"type":"string"},"chips":{"type":"array","items":{"type":"string"},"description":"Up to 6 fact chips."},"pitch":{"type":"string"},"location":{"type":"string"},"salary_min":{"type":"number"},"salary_max":{"type":"number"},"currency":{"type":"string","default":"AUD"},"fee_percent":{"type":"number","description":"The declared fee basis. Prefills from the client's fee schedule in-app."},"owner_share_percent":{"type":"integer","enum":[50,60,70,80],"default":50,"description":"The split you keep. 50/50 is the industry norm."},"candidate_aware":{"type":"boolean","description":"Candidate listings only - must be true."},"accept_terms":{"type":"boolean","description":"Accept the current Lovelio Split Terms (required once per company, on first share or first intro)."}}}}}},"responses":{"201":{"description":"Listing live on the marketplace","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/MarketplaceListing"}}}}}}}}},"/marketplace/listings/{id}":{"get":{"summary":"Get marketplace listing","operationId":"getMarketplaceListing","tags":["Marketplace"],"description":"Your own listing at any status; another agency's only while it is live.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Listing","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/MarketplaceListing"}}}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Manage marketplace listing","operationId":"updateMarketplaceListing","tags":["Marketplace"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["pause","resume","withdraw","filled","renew"]}}}}}},"responses":{"200":{"description":"Updated listing","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/MarketplaceListing"}}}}}}}}},"/marketplace/intros":{"get":{"summary":"List marketplace intros","operationId":"listMarketplaceIntros","tags":["Marketplace"],"description":"direction=received (default) lists intros waiting on your listings; direction=sent lists intros your agency made.","parameters":[{"name":"direction","in":"query","schema":{"type":"string","enum":["received","sent"],"default":"received"}},{"name":"kind","in":"query","schema":{"type":"string","enum":["submission","request"]}},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","accepted","declined","withdrawn","expired"]}},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"after","in":"query","schema":{"type":"string"},"description":"Cursor from meta.next_cursor"}],"responses":{"200":{"description":"Paginated intros","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MarketplaceIntro"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Make a marketplace intro","operationId":"createMarketplaceIntro","tags":["Marketplace"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["listing_id","kind"],"properties":{"listing_id":{"type":"string","description":"mkl_ id."},"kind":{"type":"string","enum":["submission","request"]},"candidate_id":{"type":"string","description":"cnd_ id in YOUR tenant (submissions only)."},"headline":{"type":"string","description":"Anonymised headline (submissions only)."},"chips":{"type":"array","items":{"type":"string"}},"pitch":{"type":"string","description":"Anonymised pitch (submissions only)."},"note":{"type":"string"},"attested_relationship":{"type":"boolean","description":"Submissions only - must be true."},"accept_terms":{"type":"boolean","description":"Accept the current Lovelio Split Terms (required once per company)."}}}}}},"responses":{"201":{"description":"Intro pending with the listing owner","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/MarketplaceIntro"}}}}}}}}},"/marketplace/intros/{id}":{"get":{"summary":"Get marketplace intro","operationId":"getMarketplaceIntro","tags":["Marketplace"],"description":"Visible to the two sides only: the agency that sent it and the agency whose listing it is on.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Intro","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/MarketplaceIntro"}}}}}},"404":{"description":"Not found"}}}},"/marketplace/intros/{id}/respond":{"post":{"summary":"Respond to a marketplace intro","operationId":"respondToMarketplaceIntro","tags":["Marketplace"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["check","accept","decline","withdraw"]},"decline_reason":{"type":"string"}}}}}},"responses":{"200":{"description":"check / decline / withdraw result","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"action":{"type":"string"},"prior_knowledge":{"type":"object","nullable":true,"description":"check only: { known, matched_by, added_at, matched_candidate_id, note } - the verdict, never the person. matched_candidate_id is YOUR own matching record (cnd_). Null on request intros: there the verdict belongs to the requesting agency and is stamped on the deal at accept.","properties":{"known":{"type":"boolean"},"matched_by":{"type":"string","nullable":true,"enum":["email","phone"]},"added_at":{"type":"string","nullable":true},"matched_candidate_id":{"type":"string","nullable":true},"note":{"type":"string"}}},"intro_id":{"type":"string"}}}}}}}},"201":{"description":"Accepted - the deal is formed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"action":{"type":"string","enum":["accept"]},"deal":{"$ref":"#/components/schemas/MarketplaceDeal"},"prior_knowledge":{"type":"object","nullable":true,"description":"The stamped verdict: { known, matched_by, added_at, note }. Never includes a candidate id."},"pipeline":{"type":"object","nullable":true,"description":"Submission deals: the materialised records in your tenant.","properties":{"candidate_id":{"type":"string"},"application_id":{"type":"string"}}}}}}}}}},"402":{"description":"Active plan required"},"403":{"description":"Not your move (e.g. only the sender withdraws)"},"404":{"description":"Not found - unknown id, or your agency is not a party to this intro"},"409":{"description":"Already responded - the intro is no longer pending"},"422":{"description":"Validation error"}}}},"/marketplace/deals":{"get":{"summary":"List marketplace deals","operationId":"listMarketplaceDeals","tags":["Marketplace"],"description":"Deals where your agency is a party, rendered from your side (role, your_share_percent, your_share_amount).","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["connected","in_progress","placed","dead"]}},{"name":"role","in":"query","schema":{"type":"string","enum":["owner","partner"]}},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"after","in":"query","schema":{"type":"string"},"description":"Cursor from meta.next_cursor"}],"responses":{"200":{"description":"Paginated deals","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MarketplaceDeal"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}}},"/marketplace/deals/{id}":{"get":{"summary":"Get marketplace deal","operationId":"getMarketplaceDeal","tags":["Marketplace"],"description":"Visible to the two parties only.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deal","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/MarketplaceDeal"}}}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Marketplace deal handshake","operationId":"updateMarketplaceDeal","tags":["Marketplace"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["add_to_pipeline","mark_paid","mark_received","close"]},"job_id":{"type":"string","description":"add_to_pipeline only: the receiving agency's job (job_...) to run the candidate at."},"reason":{"type":"string","description":"close only, required: why the deal is ending - the other agency reads it."}}}}}},"responses":{"200":{"description":"Updated deal","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/MarketplaceDeal"}}}}}},"402":{"description":"Active plan required"},"404":{"description":"Not found"},"422":{"description":"Validation error"}}}},"/bd/targets":{"get":{"summary":"List BD targets","operationId":"listBdTargets","tags":["Business Development"],"description":"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.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["new","pursuing","dismissed","converted"]}},{"name":"market","in":"query","schema":{"type":"string","enum":["AU","UK","US","CA"]}},{"name":"min_score","in":"query","schema":{"type":"integer"},"description":"Only targets with lookalike_score at or above this value"},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"after","in":"query","schema":{"type":"string"},"description":"Cursor from meta.next_cursor"}],"responses":{"200":{"description":"BD targets","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/BdTarget"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}}},"/bd/targets/{id}":{"get":{"summary":"Get BD target","operationId":"getBdTarget","tags":["Business Development"],"description":"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).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"BD target with events","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"allOf":[{"$ref":"#/components/schemas/BdTarget"},{"type":"object","properties":{"events":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string","enum":["market_signal","corpus_signal","touch"]},"label":{"type":"string"},"detail":{"type":"object"},"occurred_at":{"type":"string","format":"date-time"}}}}}}]}}}}}},"404":{"description":"Not found"}}}},"/bd/leads":{"get":{"summary":"List BD leads","operationId":"listBdLeads","tags":["Business Development"],"description":"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.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["open","dismissed","converted"]}},{"name":"kind","in":"query","schema":{"type":"string","enum":["new_business","client_expansion"]}},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"after","in":"query","schema":{"type":"string"},"description":"Cursor from meta.next_cursor"}],"responses":{"200":{"description":"BD leads","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/BdLead"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}}},"/bd/leads/{id}":{"get":{"summary":"Get BD lead","operationId":"getBdLead","tags":["Business Development"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"BD lead","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/BdLead"}}}}}},"404":{"description":"Not found"}}}},"/bd/patch":{"get":{"summary":"Get BD patch","operationId":"getBdPatch","tags":["Business Development"],"description":"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.","responses":{"200":{"description":"Patch and territory state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"statement":{"type":"string","nullable":true},"profile":{"type":"object","nullable":true},"created_at":{"type":"string","format":"date-time","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true},"territory":{"type":"object","nullable":true,"properties":{"watched_employers":{"type":"integer"},"last_refreshed_at":{"type":"string","format":"date-time"}}}}}}}}}}}}},"/webhooks":{"get":{"summary":"List webhooks","operationId":"listWebhooks","tags":["Webhooks"],"responses":{"200":{"description":"Webhook subscriptions","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create webhook","operationId":"createWebhook","tags":["Webhooks"],"description":"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.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["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":["*"]}}}}}},"responses":{"201":{"description":"Webhook created with signing secret","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Webhook"},"signing_secret":{"type":"string","description":"HMAC-SHA256 signing secret (whsec_ prefix). Returned only once on creation."}}}}}}}}},"/webhooks/events":{"get":{"summary":"List webhook events","operationId":"listWebhookEvents","tags":["Webhooks"],"description":"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.","responses":{"200":{"description":"Event catalogue","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"events":{"type":"array","items":{"type":"object","properties":{"event":{"type":"string"},"description":{"type":"string"}}}},"wildcard":{"type":"string","enum":["*"]}}}}}}}}}}},"/webhooks/{id}":{"get":{"summary":"Get webhook","operationId":"getWebhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Webhook details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Webhook"}}}}}}}},"patch":{"summary":"Update webhook","operationId":"updateWebhook","tags":["Webhooks"],"description":"events is validated against the same catalogue as creation (see GET /webhooks/events).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["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","*"]}},"status":{"type":"string","enum":["active","paused"]}}}}}},"responses":{"200":{"description":"Updated webhook","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Webhook"}}}}}}}},"delete":{"summary":"Delete webhook","operationId":"deleteWebhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"}}}},"/webhooks/{id}/rotate-secret":{"post":{"summary":"Rotate webhook secret","operationId":"rotateWebhookSecret","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"New signing secret","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"signing_secret":{"type":"string","description":"New HMAC-SHA256 signing secret (whsec_ prefix)."}}}}}}}}},"/webhooks/{id}/test":{"post":{"summary":"Test webhook","operationId":"testWebhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Test event sent","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/WebhookDelivery"}}}}}}}}},"/webhooks/{id}/deliveries":{"get":{"summary":"List webhook deliveries","operationId":"listDeliveries","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Delivery history","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}}},"/webhooks/{id}/deliveries/{deliveryId}/replay":{"post":{"summary":"Replay webhook delivery","operationId":"replayDelivery","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"deliveryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Delivery replayed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/WebhookDelivery"}}}}}}}}},"/activities":{"get":{"summary":"List activities","operationId":"listActivities","tags":["Activities"],"parameters":[{"$ref":"#/components/parameters/limit"},{"name":"resource_type","in":"query","schema":{"type":"string"},"description":"Filter by resource type (job, candidate, application, etc.)"},{"name":"resource_id","in":"query","schema":{"type":"string"},"description":"Filter by resource ID"},{"name":"activity_type","in":"query","schema":{"type":"string","enum":["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"]},"description":"Filter by activity type"},{"name":"after","in":"query","schema":{"type":"string","format":"date-time"},"description":"Cursor for pagination - returns activities before this ISO timestamp"},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"},"description":"Start date filter (ISO timestamp)"},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"},"description":"End date filter (ISO timestamp)"}],"responses":{"200":{"description":"Paginated activity log","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Activity"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Add a note","operationId":"createNote","tags":["Activities"],"description":"Add a note to any resource (job, candidate, application, interview, client, placement).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["resource_type","resource_id","note"],"properties":{"resource_type":{"type":"string","enum":["job","job_ad","candidate","application","interview","client","placement"]},"resource_id":{"type":"string","description":"External ID of the resource"},"note":{"type":"string","description":"Note text"}}}}}},"responses":{"201":{"description":"Note created"}}}},"/documents":{"get":{"summary":"List documents on a record","operationId":"listDocuments","tags":["Documents"],"description":"Every document filed against one record, newest first. Imported documents sort by the date they had in the previous ATS.","parameters":[{"name":"record_type","in":"query","required":true,"schema":{"type":"string","enum":["candidate","client","client_contact","job","job_ad","application","interview","placement","submission","spec","bd_target","talent_pool","team","hiring_manager"]},"description":"The kind of record to list documents for."},{"name":"record_id","in":"query","required":true,"schema":{"type":"string"},"description":"External ID of the record (cnd_..., cli_..., job_...)."}],"responses":{"200":{"description":"Documents on the record","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Document"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}},"404":{"description":"Record not found"}}},"post":{"summary":"Upload a document","operationId":"createDocument","tags":["Documents"],"description":"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.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file","record_type","record_id"],"properties":{"file":{"type":"string","format":"binary"},"record_type":{"type":"string","enum":["candidate","client","client_contact","job","job_ad","application","interview","placement","submission","spec","bd_target","talent_pool","team","hiring_manager"]},"record_id":{"type":"string","description":"External ID of the record."},"is_cv":{"type":"string","enum":["true","false"],"description":"Candidates only: make this the candidate's current CV."}}}}}},"responses":{"201":{"description":"Document stored","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Document"}}}}}},"400":{"description":"Unsupported file type, empty file, or over 25MB"},"404":{"description":"Record not found"}}}},"/documents/{id}":{"get":{"summary":"Get a document","operationId":"getDocument","tags":["Documents"],"description":"The document record. Use /documents/{id}/download for the file itself.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Document"}}}}}},"404":{"description":"Document not found"}}},"delete":{"summary":"Delete a document","operationId":"deleteDocument","tags":["Documents"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}}}}}}},"404":{"description":"Document not found"}}}},"/documents/{id}/download":{"get":{"summary":"Get a download link","operationId":"downloadDocument","tags":["Documents"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Signed download link","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"url":{"type":"string"},"file_name":{"type":"string"},"content_type":{"type":"string"},"expires_in":{"type":"integer","example":300}}}}}}}},"404":{"description":"Document not found"}}}},"/tasks/{id}":{"get":{"summary":"Get task status","operationId":"getTask","tags":["Tasks"],"description":"Poll async operation status. Use after receiving a 202 response.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Task status","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Task"}}}}}}}}},"/outreach/generate":{"post":{"summary":"Generate AI outreach","operationId":"generateOutreach","tags":["Outreach"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["candidate_id","platform"],"properties":{"candidate_id":{"type":"string","description":"cnd_ id."},"platform":{"type":"string","enum":["email","linkedin","whatsapp"]},"job_id":{"type":"string","description":"Optional job_ id. Anchors the drafts to that role."}}}}}},"responses":{"200":{"description":"Outreach variants","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"candidate_id":{"type":"string"},"job_id":{"type":"string","nullable":true},"platform":{"type":"string","enum":["email","linkedin","whatsapp"]},"variants":{"type":"array","items":{"type":"object","properties":{"subject":{"type":"string","description":"Email only."},"body":{"type":"string"},"tone":{"type":"string","enum":["professional","warm","bold"]},"estimated_length":{"type":"integer"}}}}}}}}}}}}}},"/talent-pools":{"get":{"summary":"List talent pools","operationId":"listTalentPools","tags":["Talent Pools"],"responses":{"200":{"description":"Array of talent pools","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TalentPool"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create a talent pool","operationId":"createTalentPool","tags":["Talent Pools"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"slug":{"type":"string"},"eligibility":{"type":"string"},"auto_add_enabled":{"type":"boolean"},"min_job_fit":{"type":"integer","nullable":true},"min_hm_fit":{"type":"integer","nullable":true},"description":{"type":"string"},"who_we_hire":{"type":"string"},"video_url":{"type":"string"},"is_published":{"type":"boolean"},"keep_warm_enabled":{"type":"boolean"},"cover_color":{"type":"string","enum":["cyan","green","lime","amber","orange"]}}}}}},"responses":{"201":{"description":"Talent pool created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/TalentPool"}}}}}}}}},"/talent-pools/{id}":{"get":{"summary":"Get talent pool","operationId":"getTalentPool","tags":["Talent Pools"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Talent pool","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/TalentPool"}}}}}}}},"patch":{"summary":"Update talent pool","operationId":"updateTalentPool","tags":["Talent Pools"],"description":"Update any pool field including auto-add rules and is_published.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"eligibility":{"type":"string","nullable":true},"auto_add_enabled":{"type":"boolean"},"min_job_fit":{"type":"integer","nullable":true},"min_hm_fit":{"type":"integer","nullable":true},"description":{"type":"string","nullable":true},"who_we_hire":{"type":"string","nullable":true},"video_url":{"type":"string","nullable":true},"is_published":{"type":"boolean"},"keep_warm_enabled":{"type":"boolean"},"cover_color":{"type":"string","nullable":true,"enum":["cyan","green","lime","amber","orange",null]}}}}}},"responses":{"200":{"description":"Updated talent pool","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/TalentPool"}}}}}}}},"delete":{"summary":"Delete talent pool","operationId":"deleteTalentPool","tags":["Talent Pools"],"description":"Permanently delete the pool. Its members, join requests, shares and scan verdicts go with it; the candidates themselves are never touched.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}}}}}}},"/talent-pools/{id}/members":{"get":{"summary":"List pool members","operationId":"listTalentPoolMembers","tags":["Talent Pools"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"q","in":"query","schema":{"type":"string"},"description":"Search within the pool: case-insensitive match on member name, email, job title, or employer."}],"responses":{"200":{"description":"Array of members with candidate details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Add candidate to pool","operationId":"addTalentPoolMember","tags":["Talent Pools"],"description":"Idempotent: re-adding an existing member returns 200 with already_member=true.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["candidate_id"],"properties":{"candidate_id":{"type":"string"}}}}}},"responses":{"200":{"description":"Already a member"},"201":{"description":"Added to the pool"}}}},"/talent-pools/{id}/members/{memberId}":{"delete":{"summary":"Remove pool member","operationId":"removeTalentPoolMember","tags":["Talent Pools"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"memberId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Removed"}}}},"/talent-pools/{id}/requests":{"get":{"summary":"List join requests","operationId":"listTalentPoolJoinRequests","tags":["Talent Pools"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","approved","declined"]}}],"responses":{"200":{"description":"Array of join requests","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}}},"/talent-pools/{id}/requests/{requestId}/approve":{"post":{"summary":"Approve join request","operationId":"approveTalentPoolJoinRequest","tags":["Talent Pools"],"description":"Creates or matches the candidate by email, kicks off the resume parse when a CV was attached, and adds them to the pool.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Approved"}}}},"/talent-pools/{id}/requests/{requestId}/decline":{"post":{"summary":"Decline join request","operationId":"declineTalentPoolJoinRequest","tags":["Talent Pools"],"description":"Silent - the applicant is not emailed.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Declined"}}}},"/public/talent-pools/{employerSlug}/{poolSlug}/join":{"post":{"summary":"Ask to join a talent pool","operationId":"joinTalentPool","tags":["Talent Pools"],"description":"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.","security":[],"parameters":[{"name":"employerSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"poolSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Request received","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}}}}},"/emails":{"post":{"summary":"Send candidate email now","operationId":"sendCandidateEmail","tags":["Scheduled Emails"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["candidate_id","subject","body"],"properties":{"candidate_id":{"type":"string"},"subject":{"type":"string"},"body":{"type":"string"},"to_address":{"type":"string","description":"Override recipient (default uses candidate.email)"},"job_id":{"type":"string"},"application_id":{"type":"string"}}}}}},"responses":{"201":{"description":"Email accepted for immediate send","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"thread_id":{"type":"string"},"message_id":{"type":"string","nullable":true},"candidate_id":{"type":"string"},"candidate_name":{"type":"string"},"subject":{"type":"string"},"sent_at":{"type":"string"}}}}}}}}}}},"/emails/from-description":{"post":{"summary":"Stage and stream a candidate email draft","operationId":"composeEmailFromDescription","tags":["Scheduled Emails"],"description":"Takes the recruiter intent (\"send Alice the next steps\", \"reject Ben warmly\", or freeform instructions) and streams a subject + body via Server-Sent Events. The output is funnelled through the shared brand-voice + anti-AI-tics post-processor. The response Content-Type is text/event-stream; each event is `data: <json>\\n\\n` where <json> is one of `{type:\"meta\",review_token}`, `{type:\"streaming\",partial:{subject?,body?}}`, `{type:\"complete\",draft:{subject,body,schedule_at?,template_chip?}}`, `{type:\"meta_end\",email_draft_id}`, or `{type:\"error\",message}`. The stream terminates with `data: [DONE]\\n\\n`. This endpoint stages a draft only - call POST /emails/from-description/{review_token}/confirm to send it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["candidate_id"],"properties":{"candidate_id":{"type":"string","description":"External candidate ID (cnd_ prefix)"},"application_id":{"type":"string","description":"Optional external application ID (app_ prefix) for role-aware tone"},"intent":{"type":"string","enum":["advance","reject","schedule","offer","freeform"],"description":"What kind of email to write. Defaults to freeform."},"instructions":{"type":"string","description":"Free-text hints about what the email should say"},"schedule_at":{"type":"string","description":"Optional ISO-8601 timestamp. If set, confirm schedules the send."},"source":{"type":"string","enum":["slack","mcp","dashboard","api"],"description":"Provenance tag stored on the draft row."}}}}}},"responses":{"200":{"description":"SSE stream of draft events. Content-Type: text/event-stream.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"400":{"description":"candidate_id missing or invalid, schedule_at is not ISO-8601, or body was not JSON."},"404":{"description":"Candidate not found in this company."}}}},"/emails/from-description/{review_token}":{"get":{"summary":"Get a staged email draft","operationId":"getEmailDraft","tags":["Scheduled Emails"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Draft state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object","properties":{"subject":{"type":"string"},"body":{"type":"string"},"schedule_at":{"type":"string","nullable":true},"template_chip":{"type":"string","nullable":true},"intent":{"type":"string","nullable":true}}},"candidate_id":{"type":"string","nullable":true},"application_id":{"type":"string","nullable":true},"job_id":{"type":"string","nullable":true},"status":{"type":"string"},"gaps":{"type":"array","items":{"type":"string"}},"task_status":{"type":"string"},"task_id":{"type":"string","nullable":true},"confirmed_at":{"type":"string","nullable":true}}}}}}}},"404":{"description":"Draft not found or already deleted."}}},"patch":{"summary":"Patch fields on a staged email draft","operationId":"updateEmailDraft","tags":["Scheduled Emails"],"description":"Update one or more fields on an in-flight draft (subject, body, schedule_at, template_chip). Recomputes gaps.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"subject":{"type":"string"},"body":{"type":"string"},"schedule_at":{"type":"string","nullable":true,"description":"ISO-8601 timestamp or null to clear"},"template_chip":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated draft + remaining gaps","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"422":{"description":"Draft missing, expired, already confirmed, or cancelled."}}}},"/emails/from-description/{review_token}/revise":{"post":{"summary":"Apply a free-text rewrite to a staged email draft","operationId":"reviseEmailDraft","tags":["Scheduled Emails"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["instruction"],"properties":{"instruction":{"type":"string","description":"The change the user wants, in their own words."}}}}}},"responses":{"200":{"description":"Updated subject + body","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"422":{"description":"Invalid instruction or draft in a non-revisable state."}}}},"/emails/from-description/{review_token}/confirm":{"post":{"summary":"Send or schedule a drafted email","operationId":"confirmEmailDraft","tags":["Scheduled Emails"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"Email sent or scheduled. Response body contains `email_message_id` + `email_thread_id` (immediate send) OR `scheduled_email_id` (scheduled send).","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"409":{"description":"Draft has gaps - subject or body is empty. Fill them via PATCH /emails/from-description/{review_token} or revise, then retry."},"422":{"description":"Draft missing, expired, or already confirmed."}}}},"/candidates/from-cv":{"post":{"summary":"Upload a CV and stage a candidate draft","operationId":"stageCandidateFromCv","tags":["Candidates"],"description":"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.\n\nEach 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["file_base64"],"properties":{"file_base64":{"type":"string","description":"Raw base64-encoded CV bytes (data-URL prefix optional)."},"mime_type":{"type":"string","description":"application/pdf or application/vnd.openxmlformats-officedocument.wordprocessingml.document. Defaults to application/pdf."},"source":{"type":"string","enum":["slack","mcp","dashboard","api"],"description":"Provenance tag stored on the draft row."}}}}}},"responses":{"201":{"description":"Draft staged","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"candidate_draft_id":{"type":"string","nullable":true},"draft":{"type":"object","properties":{"name":{"type":"string","nullable":true},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"linkedin_url":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"current_title":{"type":"string","nullable":true},"current_company":{"type":"string","nullable":true}}},"gaps":{"type":"object","properties":{"mandatory":{"type":"array","items":{"type":"string","enum":["name","email"]}},"warn":{"type":"array","items":{"type":"string","enum":["phone","linkedin_url"]}}}}}}}}}}},"400":{"description":"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":{"description":"QUOTA_EXCEEDED - the account has used its 5,000-CV rolling 30-day allowance."}}}},"/candidates/from-description/{review_token}":{"get":{"summary":"Get a staged candidate draft","operationId":"getCandidateDraft","tags":["Candidates"],"description":"Read current state of a staged candidate draft. Use this to poll gaps after editing.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Draft state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object"},"status":{"type":"string","enum":["draft","confirmed","cancelled"]},"gaps":{"type":"object"},"confirmed_at":{"type":"string","nullable":true}}}}}}}},"400":{"description":"Draft not found or already deleted."}}},"patch":{"summary":"Patch fields on a staged candidate draft","operationId":"updateCandidateDraft","tags":["Candidates"],"description":"Update one or more fields on an in-flight candidate draft (name, email, phone, linkedin_url, location, current_title, current_company). Recomputes gaps.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","nullable":true},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"linkedin_url":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"current_title":{"type":"string","nullable":true},"current_company":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated draft + remaining gaps","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"400":{"description":"Draft missing, expired, already confirmed, or cancelled."}}}},"/candidates/from-description/{review_token}/confirm":{"post":{"summary":"Create a candidate from a staged draft","operationId":"confirmCandidateDraft","tags":["Candidates"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"Candidate created.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"candidate_id":{"type":"string","nullable":true}}}}}}}},"400":{"description":"Draft missing, expired, or already confirmed."},"409":{"description":"Draft has gaps - name or email is empty. Fill them via PATCH /candidates/from-description/{review_token} then retry."}}}},"/interviews/from-description":{"post":{"summary":"Stage an interview draft for an application","operationId":"prepareInterviewFromDescription","tags":["Interviews"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["application_id"],"properties":{"application_id":{"type":"string","description":"External application ID (app_ prefix)"},"scheduled_at":{"type":"string","description":"Optional ISO-8601 timestamp to pre-fill the datetime"},"format":{"type":"string","enum":["phone","video","in_person","technical","panel"],"description":"Optional pre-selected format. Defaults to the job's default_interview_format."},"interviewer_id":{"type":"string","description":"Optional hiring_manager UUID. Defaults to the job's hiring_manager_id."},"duration_min":{"type":"number","description":"Optional duration in minutes. Defaults to 30."},"location":{"type":"string","description":"Optional location (required at confirm time when format=in_person)."},"notes":{"type":"string","description":"Optional interviewer notes / prep links."},"source":{"type":"string","enum":["slack","mcp","dashboard","api"],"description":"Provenance tag stored on the draft row."}}}}}},"responses":{"201":{"description":"Draft staged. Body contains review_token, draft, gaps, predicted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object"},"gaps":{"type":"array","items":{"type":"string"}},"predicted":{"type":"object"},"status":{"type":"string"}}}}}}}},"404":{"description":"Application not found in this company."},"422":{"description":"application_id missing or invalid."}}}},"/interviews/from-description/{review_token}":{"get":{"summary":"Get a staged interview draft","operationId":"getInterviewDraft","tags":["Interviews"],"description":"Read current state of a staged interview draft: scheduled_at, duration_min, format, interviewer_id, location, notes + gaps.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Draft state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object","properties":{"scheduled_at":{"type":"string","nullable":true},"duration_min":{"type":"number"},"format":{"type":"string","nullable":true},"interviewer_id":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true}}},"application_id":{"type":"string"},"candidate_id":{"type":"string","nullable":true},"job_id":{"type":"string","nullable":true},"status":{"type":"string"},"gaps":{"type":"array","items":{"type":"string"}},"confirmed_at":{"type":"string","nullable":true}}}}}}}},"404":{"description":"Draft not found or already deleted."}}},"patch":{"summary":"Patch fields on a staged interview draft","operationId":"updateInterviewDraft","tags":["Interviews"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"scheduled_at":{"type":"string","nullable":true,"description":"ISO-8601 timestamp or null to clear"},"duration_min":{"type":"number"},"format":{"type":"string","enum":["phone","video","in_person","technical","panel"],"nullable":true},"interviewer_id":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated draft + remaining gaps","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"422":{"description":"Draft missing, already confirmed, or invalid field value."}}}},"/interviews/from-description/{review_token}/confirm":{"post":{"summary":"Book the drafted interview","operationId":"confirmInterviewDraft","tags":["Interviews"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"scheduled_at":{"type":"string","format":"date-time","description":"Re-confirm at this time (after a SCHEDULING_CONFLICT refusal offered suggestions). Patches the draft through the one update path before booking."},"ignore_conflicts":{"type":"boolean","default":false,"description":"Book through a known free/busy clash - an explicit override, never the default."}}}}}},"responses":{"201":{"description":"Interview booked. Response body contains `interview_id`.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"interview_id":{"type":"string"}}}}}}}},"409":{"description":"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":{"description":"Draft missing, expired, or already confirmed."}}}},"/interviews/{id}/action":{"post":{"summary":"Stage a reschedule or cancel draft for an existing interview","operationId":"prepareInterviewAction","tags":["Interviews"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["reschedule","cancel"]},"new_scheduled_at":{"type":"string","description":"ISO-8601 timestamp. Required at confirm time when action=reschedule."},"reason":{"type":"string","description":"Free-text reason. Required at confirm time when action=cancel."},"notify_candidate":{"type":"boolean","description":"Whether the candidate is emailed about the change. Defaults true."},"source":{"type":"string","enum":["slack","mcp","dashboard","api"],"description":"Provenance tag stored on the draft row."}}}}}},"responses":{"201":{"description":"Draft staged. Body contains review_token, draft, gaps, action.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object"},"gaps":{"type":"array","items":{"type":"string"}},"status":{"type":"string"},"action":{"type":"string"}}}}}}}},"404":{"description":"Interview not found in this company."},"422":{"description":"Missing / invalid action, or interview already cancelled."}}}},"/interviews/action-drafts/{review_token}":{"get":{"summary":"Get a staged reschedule / cancel draft","operationId":"getInterviewActionDraft","tags":["Interviews"],"description":"Read current state of a staged reschedule / cancel draft: action, new_scheduled_at, reason, notify_candidate + gaps.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Draft state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object","properties":{"action":{"type":"string","enum":["reschedule","cancel"]},"new_scheduled_at":{"type":"string","nullable":true},"reason":{"type":"string","nullable":true},"notify_candidate":{"type":"boolean"}}},"interview_id":{"type":"string"},"status":{"type":"string"},"gaps":{"type":"array","items":{"type":"string"}},"confirmed_at":{"type":"string","nullable":true}}}}}}}},"404":{"description":"Draft not found or already deleted."}}},"patch":{"summary":"Patch fields on a staged reschedule / cancel draft","operationId":"updateInterviewActionDraft","tags":["Interviews"],"description":"Update one or more fields on an in-flight reschedule / cancel draft. Recomputes gaps. Fields: new_scheduled_at, reason, notify_candidate.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"new_scheduled_at":{"type":"string","nullable":true,"description":"ISO-8601 timestamp or null to clear"},"reason":{"type":"string","nullable":true},"notify_candidate":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated draft + remaining gaps","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"422":{"description":"Draft missing, already confirmed, or invalid field value."}}}},"/interviews/action-drafts/{review_token}/confirm":{"post":{"summary":"Confirm a reschedule or cancel draft","operationId":"confirmInterviewActionDraft","tags":["Interviews"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"new_scheduled_at":{"type":"string","format":"date-time","description":"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":{"type":"boolean","default":false,"description":"Move through a known free/busy clash - an explicit override, never the default."}}}}}},"responses":{"201":{"description":"Confirmed. Response body contains `interview_id`, `action`, `notify_candidate`.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"interview_id":{"type":"string"},"action":{"type":"string"},"notify_candidate":{"type":"boolean"}}}}}}}},"409":{"description":"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":{"description":"Draft missing, expired, or already confirmed."}}}},"/phone-screens/from-description":{"post":{"summary":"Stage a phone-screen log","operationId":"composePhoneScreenFromDescription","tags":["Applications"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["application_id"],"properties":{"application_id":{"type":"string","description":"External application ID (app_ prefix)"},"initial_outcome":{"type":"string","enum":["advance","reject","hold","no_show"],"description":"Pre-fill outcome on the draft. Caller can change it via PATCH before confirm."},"initial_notes":{"type":"string","description":"Pre-fill notes on the draft. Required before confirm succeeds."},"call_at":{"type":"string","description":"Optional ISO-8601 timestamp of the call."},"duration_min":{"type":"number","description":"Optional call duration in minutes."},"source":{"type":"string","enum":["slack","mcp","dashboard","api"],"description":"Provenance tag stored on the draft row."}}}}}},"responses":{"201":{"description":"Draft staged. Returns review_token, current draft, and remaining gaps.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"application_id":{"type":"string"},"draft":{"type":"object","properties":{"outcome":{"type":"string","nullable":true},"notes":{"type":"string"},"call_at":{"type":"string","nullable":true},"duration_min":{"type":"number","nullable":true}}},"gaps":{"type":"array","items":{"type":"string"}},"status":{"type":"string"}}}}}}}},"400":{"description":"application_id missing or invalid, or body was not JSON."},"404":{"description":"Application not found in this company."}}}},"/phone-screens/from-description/{review_token}":{"get":{"summary":"Get a staged phone-screen draft","operationId":"getPhoneScreenDraft","tags":["Applications"],"description":"Read current state of a staged phone-screen draft: outcome, notes, call metadata, and remaining gaps.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Draft state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"application_id":{"type":"string"},"draft":{"type":"object","properties":{"outcome":{"type":"string","nullable":true},"notes":{"type":"string"},"call_at":{"type":"string","nullable":true},"duration_min":{"type":"number","nullable":true}}},"status":{"type":"string"},"gaps":{"type":"array","items":{"type":"string"}},"confirmed_at":{"type":"string","nullable":true}}}}}}}},"404":{"description":"Draft not found or already deleted."}}},"patch":{"summary":"Patch fields on a staged phone-screen draft","operationId":"updatePhoneScreenDraft","tags":["Applications"],"description":"Update one or more fields on an in-flight phone-screen draft (outcome, notes, call_at, duration_min). Recomputes gaps.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"outcome":{"type":"string","enum":["advance","reject","hold","no_show"],"nullable":true},"notes":{"type":"string","nullable":true},"call_at":{"type":"string","nullable":true,"description":"ISO-8601 timestamp or null to clear"},"duration_min":{"type":"number","nullable":true}}}}}},"responses":{"200":{"description":"Updated draft + remaining gaps","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"422":{"description":"Draft missing, expired, or already confirmed."}}}},"/phone-screens/from-description/{review_token}/confirm":{"post":{"summary":"Confirm a phone-screen log","operationId":"confirmPhoneScreenDraft","tags":["Applications"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"Phone screen logged. Response contains application_id, outcome, new_stage (nullable for hold / no_show), note_id.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"409":{"description":"Draft has gaps - outcome or notes is empty. Fill them via PATCH /phone-screens/from-description/{review_token}, then retry."},"422":{"description":"Draft missing, expired, or already confirmed."}}}},"/scorecards/from-description":{"post":{"summary":"Stage a scorecard draft for an interview","operationId":"stageScorecardDraft","tags":["Interviews"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["interview_id"],"properties":{"interview_id":{"type":"string","description":"External interview id (ivw_...)."}}}}}},"responses":{"201":{"description":"Draft staged","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object","properties":{"scores":{"type":"array","items":{"type":"object","properties":{"criterion_id":{"type":"string"},"criterion_name":{"type":"string"},"category":{"type":"string","enum":["knockout","role_specific","culture","communication","overall"]},"scoring_type":{"type":"string","enum":["pass_fail","scale_1_5"]},"score":{"type":"number","nullable":true},"note":{"type":"string"}}}},"overall_recommendation":{"type":"string","enum":["strong_hire","hire","no_hire","strong_no_hire"],"nullable":true},"feedback":{"type":"string","nullable":true}}},"context":{"type":"object","properties":{"interview_id":{"type":"string"},"application_id":{"type":"string","nullable":true},"job_id":{"type":"string","nullable":true},"candidate_name":{"type":"string","nullable":true},"job_title":{"type":"string","nullable":true}}},"gaps":{"type":"object","properties":{"mandatory":{"type":"array","items":{"type":"string","enum":["overall_recommendation","scores"]}},"warn":{"type":"array","items":{"type":"string","enum":["feedback"]}}}}}}}}}}},"400":{"description":"Invalid or missing interview_id."}}}},"/scorecards/from-description/{review_token}":{"get":{"summary":"Get a staged scorecard draft","operationId":"getScorecardDraft","tags":["Interviews"],"description":"Read the current state of a staged scorecard draft plus remaining mandatory and warn-level gaps.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Draft state","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"draft":{"type":"object"},"context":{"type":"object"},"status":{"type":"string","enum":["draft","confirmed","cancelled"]},"gaps":{"type":"object"},"confirmed_at":{"type":"string","nullable":true}}}}}}}},"400":{"description":"Draft not found or already deleted."}}},"patch":{"summary":"Patch fields on a staged scorecard draft","operationId":"updateScorecardDraft","tags":["Interviews"],"description":"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.","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"scores":{"type":"array","description":"Full replacement of the per-criterion scores array.","items":{"type":"object","properties":{"criterion_id":{"type":"string"},"criterion_name":{"type":"string"},"category":{"type":"string"},"scoring_type":{"type":"string"},"score":{"type":"number","nullable":true},"note":{"type":"string"}}}},"criterion_id":{"type":"string","description":"Single-entry patch: patch this criterion with score + note."},"score":{"type":"number","nullable":true},"note":{"type":"string"},"overall_recommendation":{"type":"string","enum":["strong_hire","hire","no_hire","strong_no_hire"],"nullable":true},"feedback":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated draft + remaining gaps","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}},"400":{"description":"Draft missing, expired, already confirmed, or cancelled."}}}},"/scorecards/from-description/{review_token}/confirm":{"post":{"summary":"Submit a staged scorecard draft","operationId":"confirmScorecardDraft","tags":["Interviews"],"description":"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).","parameters":[{"name":"review_token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"Scorecard submitted and interview outcome updated.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"review_token":{"type":"string"},"interview_id":{"type":"string","nullable":true},"outcome":{"type":"string","enum":["Passed","Failed"]}}}}}}}},"400":{"description":"Draft missing, expired, or already confirmed."},"409":{"description":"Draft has gaps - overall_recommendation is missing or every score is null. Fill them via PATCH /scorecards/from-description/{review_token} then retry."},"422":{"description":"Draft missing, expired, or already confirmed."}}}},"/integrations/slack":{"get":{"summary":"Get Slack integration","operationId":"getSlackIntegration","tags":["Integrations"],"responses":{"200":{"description":"Slack connection status","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}},"patch":{"summary":"Update Slack settings","operationId":"updateSlackIntegration","tags":["Integrations"],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}},"delete":{"summary":"Disconnect Slack","operationId":"deleteSlackIntegration","tags":["Integrations"],"responses":{"204":{"description":"Disconnected"}}}},"/integrations/chat":{"get":{"summary":"List chat integrations","operationId":"listChatIntegrations","tags":["Chat Integrations"],"description":"List all connected chat platforms (Slack, Teams, WhatsApp, Google Chat) for the company.","responses":{"200":{"description":"Array of connected platforms with chatbot status","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object"}}}}}}}}},"post":{"summary":"Connect chat platform","operationId":"connectChatPlatform","tags":["Chat Integrations"],"description":"Programmatically connect a chat platform for the AI chatbot. Requires platform credentials.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["platform","workspace_id","workspace_name"],"properties":{"platform":{"type":"string","enum":["slack","teams","whatsapp","google_chat","discord","telegram","github","linear"]},"workspace_id":{"type":"string","description":"Platform workspace/team ID"},"workspace_name":{"type":"string","description":"Human-readable workspace name"},"credentials":{"type":"object","description":"Platform-specific credentials (bot_token for Slack, etc.)"}}}}}},"responses":{"201":{"description":"Integration created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}}},"/integrations/chat/{id}":{"patch":{"summary":"Update chat integration","operationId":"updateChatIntegration","tags":["Chat Integrations"],"description":"Enable/disable the chatbot or update settings for a connected platform.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Integration ID (slk_ or cin_ prefix)"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"chatbot_enabled":{"type":"boolean"},"settings":{"type":"object"}}}}}},"responses":{"200":{"description":"Updated integration","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}},"delete":{"summary":"Disconnect chat platform","operationId":"disconnectChatPlatform","tags":["Chat Integrations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Disconnected","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}}}}},"/integrations/chat/{id}/users":{"get":{"summary":"List user mappings","operationId":"listChatUserMappings","tags":["Chat Integrations"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Array of user mappings","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object"}}}}}}}}},"post":{"summary":"Create user mapping","operationId":"createChatUserMapping","tags":["Chat Integrations"],"description":"Manually map a platform user to a Lovelio user. Useful for bulk provisioning or when auto-mapping by email is not possible.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["platform_user_id","platform_workspace_id","user_id"],"properties":{"platform_user_id":{"type":"string","description":"Platform-specific user ID (e.g. Slack user ID)"},"platform_workspace_id":{"type":"string","description":"Platform workspace ID"},"user_id":{"type":"string","description":"Lovelio user ID (usr_ prefix)"},"display_name":{"type":"string"},"role":{"type":"string"}}}}}},"responses":{"201":{"description":"Mapping created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}}},"/integrations/chat/{id}/users/{userId}":{"delete":{"summary":"Delete user mapping","operationId":"deleteChatUserMapping","tags":["Chat Integrations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"userId","in":"path","required":true,"schema":{"type":"string"},"description":"User mapping ID (cum_ prefix)"}],"responses":{"200":{"description":"Mapping deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}}}}},"/analytics/dashboard":{"get":{"summary":"Dashboard statistics","operationId":"getDashboard","tags":["Analytics"],"description":"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.","responses":{"200":{"description":"Dashboard data","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}}},"/analytics/pipeline":{"get":{"summary":"Pipeline analysis","operationId":"getPipelineAnalysis","tags":["Analytics"],"description":"Hiring funnel analysis with conversion rates between stages and biggest dropoff identification.","parameters":[{"name":"days","in":"query","schema":{"type":"integer","default":30,"minimum":1,"maximum":365},"description":"Lookback period in days (clamped to 365)"},{"name":"job_id","in":"query","schema":{"type":"string"},"description":"Filter to a specific job"}],"responses":{"200":{"description":"Funnel analysis with conversion rates","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}}},"/analytics/metrics":{"get":{"summary":"Hiring metrics","operationId":"getHiringMetrics","tags":["Analytics"],"description":"Key hiring KPIs - applications received, hires made, offer acceptance rate, interview-to-hire ratio, source breakdown.","parameters":[{"name":"days","in":"query","schema":{"type":"integer","default":30,"minimum":1,"maximum":365},"description":"Lookback period in days (clamped to 365)"}],"responses":{"200":{"description":"Hiring KPIs","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}}}},"/applications/{id}/stage":{"post":{"summary":"Transition application stage","operationId":"transitionApplicationStage","tags":["Applications"],"description":"Moves an application to a new pipeline stage. Validates the transition against the canonical pipeline. To force an invalid transition, pass an override_reason.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Application external ID (app_ prefix)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["stage","source"],"properties":{"stage":{"type":"string","enum":["funnel","maybe","yes","no","screen","submitted","client_interview","references","offer","placed","rejected","withdrew"]},"source":{"type":"string","enum":["slack","whatsapp","web","api"]},"override_reason":{"type":"string","description":"Required to force a transition that is not in the canonical allow-list."}}}}}},"responses":{"200":{"description":"Stage transition completed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"application":{"type":"object","properties":{"id":{"type":"string"},"stage":{"type":"string"},"stage_changed_at":{"type":"string","format":"date-time"}}}}}}}}}},"404":{"description":"Application not found"},"422":{"description":"Invalid stage or disallowed transition"}}}},"/review-queue":{"get":{"summary":"List review queue items","operationId":"listReviewQueue","tags":["Review Queue"],"description":"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.","parameters":[{"name":"resolved","in":"query","schema":{"type":"string","enum":["true","false"]},"description":"Filter by resolved state. Defaults to false."},{"name":"review_type","in":"query","schema":{"type":"string","enum":["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"]}},{"name":"priority","in":"query","schema":{"type":"string","enum":["urgent","high","normal","low"]}},{"name":"include_snoozed","in":"query","schema":{"type":"boolean","default":false},"description":"If true, include items whose snoozed_until is in the future. Defaults to false."},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"created_at cursor for pagination"}],"responses":{"200":{"description":"List of review queue items","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQueueItem"}},"meta":{"type":"object","properties":{"count":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true}}}}}}}}}}},"/review-queue/{id}":{"patch":{"summary":"Update a review queue item","operationId":"updateReviewQueueItem","tags":["Review Queue"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Review queue external ID (rev_ prefix)"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"resolved":{"type":"boolean"},"priority":{"type":"string","enum":["urgent","high","normal","low"]},"snoozed_until":{"type":"string","format":"date-time","nullable":true,"description":"ISO-8601 timestamp to hide the item until, or null to clear the snooze."}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ReviewQueueItem"}}}}}},"404":{"description":"Not found"}}}},"/forms":{"get":{"summary":"List forms","operationId":"listForms","tags":["Forms"],"responses":{"200":{"description":"List of form templates","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Form"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create form","operationId":"createForm","tags":["Forms"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["name","category"],"properties":{"name":{"type":"string"},"category":{"type":"string","enum":["screening","assessment","interview","decision","offer","references","onboarding","compliance"]},"description":{"type":"string"},"ai_augmentation_enabled":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Form created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Form"}}}}}}}}},"/forms/{id}":{"get":{"summary":"Get form with questions","operationId":"getForm","tags":["Forms"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Form with questions","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"form":{"$ref":"#/components/schemas/Form"},"questions":{"type":"array","items":{"$ref":"#/components/schemas/FormQuestion"}}}}}}}}}}},"patch":{"summary":"Update form","operationId":"updateForm","tags":["Forms"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Updated form","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Form"}}}}}}}},"delete":{"summary":"Delete form","operationId":"deleteForm","tags":["Forms"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}}}}},"/forms/{id}/questions":{"post":{"summary":"Add question to form","operationId":"addFormQuestion","tags":["Forms"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["question_text"],"properties":{"question_text":{"type":"string"},"question_type":{"type":"string","enum":["text","rating","yes_no","multiple_choice"]},"options":{"type":"array","items":{"type":"string"}},"scoring_weight":{"type":"number"},"flag_mode":{"type":"string","enum":["red","green","neutral"]},"required":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Question added","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/FormQuestion"}}}}}}}}},"/forms/{id}/questions/{questionId}":{"patch":{"summary":"Update question","operationId":"updateFormQuestion","tags":["Forms"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"questionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/FormQuestion"}}}}}}}},"delete":{"summary":"Delete question","operationId":"deleteFormQuestion","tags":["Forms"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"questionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}}}}},"/form-instances":{"get":{"summary":"List form instances","operationId":"listFormInstances","tags":["Forms"],"description":"Returns compact summaries of form instances for discovery and pagination. Use GET /v1/form-instances/{id} for full question and response data.","parameters":[{"name":"instance_type","in":"query","schema":{"type":"string","enum":["screening","reference"]}},{"name":"status","in":"query","schema":{"type":"string","enum":["draft","sent","opened","started","completed"]}},{"name":"application_id","in":"query","schema":{"type":"string"}},{"name":"referee_id","in":"query","schema":{"type":"string"}},{"name":"form_id","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"created_at cursor for pagination"}],"responses":{"200":{"description":"List of form instances","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FormInstance"}},"meta":{"type":"object"}}}}}}}}},"/form-instances/{id}":{"get":{"summary":"Get form instance","operationId":"getFormInstance","tags":["Forms"],"description":"Returns a form instance with all questions and responses.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Form instance with questions and responses","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/FormInstance"}}}}}}}}},"/form-instances/screening":{"post":{"summary":"Create screening instance","operationId":"createScreeningInstance","tags":["Forms"],"description":"Creates a phone screen form instance for an application. Copies questions from the screening form template.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["application_id"],"properties":{"application_id":{"type":"string"},"form_id":{"type":"string","description":"Optional - defaults to the account default screening form"}}}}}},"responses":{"201":{"description":"Screening instance created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/FormInstance"}}}}}}}}},"/form-instances/reference":{"post":{"summary":"Create reference instance","operationId":"createReferenceInstance","tags":["Forms"],"description":"Creates a reference check form instance for a referee. If the form has AI augmentation enabled, generates additional targeted questions.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["application_id","referee_id"],"properties":{"application_id":{"type":"string"},"referee_id":{"type":"string"},"form_id":{"type":"string","description":"Optional - defaults to the account default assessment form"}}}}}},"responses":{"201":{"description":"Reference instance created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/FormInstance"}}}}}}}}},"/form-instances/{id}/send":{"post":{"summary":"Send reference form to referee","operationId":"sendFormInstance","tags":["Forms"],"description":"Generates a magic link and sends the reference form to the referee via email.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Form sent","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/FormInstance"}}}}}}}}},"/candidates/{id}/referees":{"get":{"summary":"List referees","operationId":"listReferees","tags":["Referees"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"List of referees for this candidate","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Referee"}}}}}}}}},"post":{"summary":"Add referee","operationId":"createReferee","tags":["Referees"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["name","email"],"properties":{"name":{"type":"string"},"email":{"type":"string","format":"email"},"phone":{"type":"string"},"job_title":{"type":"string"}}}}}},"responses":{"201":{"description":"Referee added","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Referee"}}}}}}}}},"/candidates/{id}/referees/{refereeId}":{"patch":{"summary":"Update referee","operationId":"updateReferee","tags":["Referees"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"refereeId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Referee"}}}}}}}},"delete":{"summary":"Delete referee","operationId":"deleteReferee","tags":["Referees"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"refereeId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"deleted":{"type":"boolean"},"id":{"type":"string"}}}}}}}}},"/stages":{"get":{"summary":"List canonical pipeline stages","operationId":"listStages","tags":["Stages"],"description":"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.","responses":{"200":{"description":"List of stages","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"yes","enum":["funnel","maybe","yes","no","screen","submitted","client_interview","references","offer","placed","rejected","withdrew"]},"label":{"type":"string","example":"Yes"},"description":{"type":"string"},"is_terminal":{"type":"boolean"},"is_default":{"type":"boolean","description":"True for the state new applications start in (funnel)."}}}}}}}}}}}},"/email-templates":{"get":{"summary":"List email templates","operationId":"listEmailTemplates","tags":["Email Templates"],"description":"Returns the merged view of every canonical Lovelio email template and any per-company overrides. Filter to a single template with template_key.","parameters":[{"name":"template_key","in":"query","schema":{"type":"string"},"description":"Optional: return only the specified template."}],"responses":{"200":{"description":"List of email templates","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EmailTemplate"}}}}}}}}},"patch":{"summary":"Update one or more email templates","operationId":"updateEmailTemplates","tags":["Email Templates"],"description":"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.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["updates"],"properties":{"updates":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"object","required":["template_key"],"properties":{"template_key":{"type":"string"},"ai_instructions":{"type":"string","nullable":true},"enabled":{"type":"boolean"}}}}}}}}},"responses":{"200":{"description":"Updated templates","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EmailTemplate"}}}}}}},"422":{"description":"Validation error"}}}},"/workflow-rules":{"get":{"summary":"Get workflow rules","operationId":"getWorkflowRules","tags":["Workflow Rules"],"description":"Returns the current workflow rules for the company. A default row is auto-created on first read.","responses":{"200":{"description":"Current workflow rules","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/WorkflowRules"}}}}}}}},"patch":{"summary":"Update workflow rules","operationId":"updateWorkflowRules","tags":["Workflow Rules"],"description":"Update any subset of the workflow rules fields. Each reject threshold must stay strictly below its matching yes threshold.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRules"}}}},"responses":{"200":{"description":"Updated workflow rules","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/WorkflowRules"}}}}}},"422":{"description":"Validation error"}}}},"/calendar-events":{"get":{"summary":"List calendar events","operationId":"listCalendarEvents","tags":["Calendar"],"description":"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.","parameters":[{"name":"start","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only return events starting at or after this ISO-8601 timestamp."},{"name":"end","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only return events starting strictly before this ISO-8601 timestamp."},{"name":"event_type","in":"query","schema":{"type":"string","enum":["interview","phone_screen","reminder","deadline","meeting"]}},{"name":"application_id","in":"query","schema":{"type":"string"}},{"name":"candidate_id","in":"query","schema":{"type":"string"}},{"name":"job_id","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"starts_at cursor for forward pagination."}],"responses":{"200":{"description":"List of calendar events","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/CalendarEvent"}},"meta":{"type":"object","properties":{"count":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true}}}}}}}}}},"post":{"summary":"Create calendar event","operationId":"createCalendarEvent","tags":["Calendar"],"description":"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.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["title","event_type","starts_at"],"properties":{"title":{"type":"string"},"event_type":{"type":"string","enum":["interview","phone_screen","reminder","deadline","meeting"]},"starts_at":{"type":"string","format":"date-time"},"ends_at":{"type":"string","format":"date-time","nullable":true},"all_day":{"type":"boolean"},"notes":{"type":"string"},"application_id":{"type":"string"},"job_id":{"type":"string"},"candidate_id":{"type":"string"},"assigned_to":{"type":"string","description":"User ID the event is assigned to."}}}}}},"responses":{"201":{"description":"Calendar event created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/CalendarEvent"}}}}}},"422":{"description":"Validation error"}}}},"/calendar-events/{id}":{"get":{"summary":"Get calendar event","operationId":"getCalendarEvent","tags":["Calendar"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Calendar event external ID (cal_ prefix)"}],"responses":{"200":{"description":"Calendar event","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/CalendarEvent"}}}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Update calendar event","operationId":"updateCalendarEvent","tags":["Calendar"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"event_type":{"type":"string","enum":["interview","phone_screen","reminder","deadline","meeting"]},"starts_at":{"type":"string","format":"date-time"},"ends_at":{"type":"string","format":"date-time","nullable":true},"all_day":{"type":"boolean"},"notes":{"type":"string","nullable":true},"assigned_to":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated calendar event","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/CalendarEvent"}}}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete calendar event","operationId":"deleteCalendarEvent","tags":["Calendar"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted"},"404":{"description":"Not found"}}}},"/scheduled-emails":{"get":{"summary":"List scheduled emails","operationId":"listScheduledEmails","tags":["Scheduled Emails"],"description":"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.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["draft","scheduled","sending","sent","cancelled","failed"]}},{"name":"candidate_id","in":"query","schema":{"type":"string"}},{"name":"scheduled_before","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"scheduled_after","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"scheduled_at cursor for forward pagination."}],"responses":{"200":{"description":"List of scheduled emails","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledEmail"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create scheduled email","operationId":"createScheduledEmail","tags":["Scheduled Emails"],"description":"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.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["candidate_id","subject","scheduled_at"],"properties":{"candidate_id":{"type":"string","description":"External candidate ID (cnd_)."},"to_address":{"type":"string","description":"Optional override for the destination email. Defaults to the candidate's primary email."},"cc_addresses":{"type":"array","items":{"type":"string"}},"bcc_addresses":{"type":"array","items":{"type":"string"}},"subject":{"type":"string"},"body_html":{"type":"string","description":"Optional HTML body. Either body_html or body_text is required."},"body_text":{"type":"string","description":"Optional plain-text body. Either body_html or body_text is required."},"scheduled_at":{"type":"string","format":"date-time","description":"When to fire. Must be in the future."},"application_id":{"type":"string"},"job_id":{"type":"string"},"created_by":{"type":"string","description":"Optional external user ID (usr_) to credit. Defaults to the company's first admin."}}}}}},"responses":{"201":{"description":"Scheduled email created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ScheduledEmail"}}}}}},"422":{"description":"Validation error"}}}},"/scheduled-emails/{id}":{"get":{"summary":"Get scheduled email","operationId":"getScheduledEmail","tags":["Scheduled Emails"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Scheduled email external ID (sem_ prefix)."}],"responses":{"200":{"description":"Scheduled email details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ScheduledEmail"}}}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Update scheduled email","operationId":"updateScheduledEmail","tags":["Scheduled Emails"],"description":"Edit a scheduled email before it fires. Only permitted when status='scheduled'. Once the cron has claimed the row the API returns 422.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"to_address":{"type":"string"},"cc_addresses":{"type":"array","items":{"type":"string"}},"bcc_addresses":{"type":"array","items":{"type":"string"}},"subject":{"type":"string"},"body_html":{"type":"string","nullable":true},"body_text":{"type":"string","nullable":true},"scheduled_at":{"type":"string","format":"date-time"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ScheduledEmail"}}}}}},"404":{"description":"Not found"},"422":{"description":"No longer editable (already sent/cancelled/failed) or validation error"}}},"delete":{"summary":"Cancel scheduled email","operationId":"cancelScheduledEmail","tags":["Scheduled Emails"],"description":"Soft-cancel a scheduled email by setting status='cancelled'. Row is preserved for audit. Only permitted when status='scheduled'.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ScheduledEmail"}}}}}},"422":{"description":"Not in a cancellable state"}}}},"/scheduled-emails/{id}/send-now":{"post":{"summary":"Send scheduled email now","operationId":"sendScheduledEmailNow","tags":["Scheduled Emails"],"description":"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'.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Scheduled for immediate send","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ScheduledEmail"}}}}}},"422":{"description":"Not in a sendable state"}}}},"/accounts/{id}/users":{"get":{"summary":"List users","operationId":"listUsers","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Array of users","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/User"}}}}}}}}},"post":{"summary":"Invite a user","operationId":"createUser","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["name","email"],"properties":{"name":{"type":"string","description":"Full name. Alternatively pass first_name + last_name."},"first_name":{"type":"string","description":"Given name. Preferred over name."},"last_name":{"type":"string","description":"Family name."},"email":{"type":"string","format":"email","description":"Must be on the account's email_domain when one is set, else the invite is rejected."},"phone":{"type":"string"},"role":{"type":"string","enum":["admin","team_leader","recruiter"]},"team_id":{"type":"string","description":"Team (tem_ id) the user joins when they accept the invite."},"role_title":{"type":"string"},"linkedin_url":{"type":"string"},"slack_user_id":{"type":"string"},"whatsapp_number":{"type":"string"}}}}}},"responses":{"201":{"description":"User invited","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/User"}}}}}}}}},"/accounts/{id}/users/{userId}":{"get":{"summary":"Get user","operationId":"getUser","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/User"}}}}}}}},"patch":{"summary":"Update user","operationId":"updateUser","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Full name. Alternatively pass first_name + last_name; either way first/last and the display name stay in step."},"first_name":{"type":"string"},"last_name":{"type":"string"},"email":{"type":"string","format":"email","description":"Must be on the account's email_domain when one is set."},"phone":{"type":"string"},"role":{"type":"string","enum":["admin","team_leader","recruiter"]},"role_title":{"type":"string"},"team_id":{"type":"string","nullable":true,"description":"Assign the member to a team (tem_ id), or null to clear. Requires the user to have accepted their invite."},"linkedin_url":{"type":"string"},"slack_user_id":{"type":"string"},"whatsapp_number":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated user","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/User"}}}}}}}},"delete":{"summary":"Delete user","operationId":"deleteUser","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"User deleted"}}}},"/accounts/{id}/teams":{"get":{"summary":"List teams","operationId":"listTeams","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Array of teams","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Team"}}}}}}}}},"post":{"summary":"Create team","operationId":"createTeam","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"parent_team_id":{"type":"string","nullable":true,"description":"Parent team this team nests under, or null/omitted for top level."}}}}}},"responses":{"201":{"description":"Team created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Team"}}}}}}}}},"/accounts/{id}/teams/{teamId}":{"get":{"summary":"Get team","operationId":"getTeam","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"teamId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Team details","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Team"}}}}}}}},"patch":{"summary":"Update team","operationId":"updateTeam","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"teamId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"parent_team_id":{"type":"string","nullable":true,"description":"Re-parent the team. Send null to move it back to the top level. Must not create a cycle."}}}}}},"responses":{"200":{"description":"Updated team","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Team"}}}}}}}},"delete":{"summary":"Delete team","operationId":"deleteTeam","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"teamId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Team deleted"}}}},"/accounts/{id}/api-keys":{"get":{"summary":"List API keys","operationId":"listApiKeys","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Array of API keys","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}}}}}},"post":{"summary":"Create API key","operationId":"createApiKey","tags":["Accounts"],"description":"Returns the full API key once - store it securely.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["name","scopes"],"properties":{"name":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"201":{"description":"API key created with full key value","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/ApiKey"},"key":{"type":"string","description":"Full API key value. Shown only once on creation."}}}}}}}}},"/accounts/{id}/api-keys/{keyId}":{"delete":{"summary":"Revoke API key","operationId":"revokeApiKey","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"keyId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"API key revoked"}}}},"/accounts/{id}/sso":{"get":{"summary":"List SSO connections","operationId":"listSsoConnections","tags":["Accounts"],"description":"Returns all SSO connections configured for this account.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Array of SSO connections","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/SsoConnection"}}}}}}}}},"post":{"summary":"Create SSO connection","operationId":"createSsoConnection","tags":["Accounts"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["domain","provider"],"properties":{"domain":{"type":"string","example":"acme.com"},"provider":{"type":"string","enum":["google","microsoft","saml"]}}}}}},"responses":{"201":{"description":"Connection created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/SsoConnection"}}}}}},"402":{"description":"Plan upgrade required"},"409":{"description":"Connection already exists for this domain"},"503":{"description":"SSO provider not configured on server"}}}},"/accounts/{id}/sso/{connectionId}":{"delete":{"summary":"Remove SSO connection","operationId":"deleteSsoConnection","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"connectionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Connection deleted"}}}},"/accounts/{id}/transcription-usage":{"get":{"summary":"Get video transcription usage","operationId":"getTranscriptionUsage","tags":["Accounts"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Usage snapshot","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"plan_status":{"type":"string","enum":["trialing","active","past_due","cancelled"]},"cap_minutes":{"type":"integer","nullable":true,"description":"null means unlimited."},"used_minutes":{"type":"integer"},"remaining_minutes":{"type":"integer","nullable":true,"description":"null means unlimited."},"month":{"type":"string","description":"YYYY-MM (UTC)."}}}}}}}}}}},"/accounts/{id}/upgrade":{"post":{"summary":"Activate account outside Stripe Checkout","operationId":"upgradeAccount","tags":["Accounts"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"seats":{"type":"integer","minimum":1,"default":1}}}}}},"responses":{"200":{"description":"Account upgraded","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"account_id":{"type":"string","example":"acc_550e8400e29b41d4a716446655440000"},"seats":{"type":"integer"},"plan_tier":{"type":"string","enum":["premium"],"example":"premium"},"plan_status":{"type":"string","example":"active"},"live_api_key":{"type":"string","nullable":true,"description":"The new live API key, returned ONCE when a trial key was promoted. Null if no active trial key existed."},"billing_portal_url":{"type":"string"}}}}}}}}}}},"/accounts/{id}/intake-slo":{"get":{"summary":"Get intake pipeline SLO","operationId":"getIntakeSlo","tags":["Accounts"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Intake SLO reading","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"window":{"type":"string","example":"30d"},"sample_size":{"type":"integer","description":"Runs the percentiles were computed from - the most recent 5,000 in the window."},"total_events":{"type":"integer","description":"Exact number of intake runs in the window. Equals sample_size unless the account ran more than 5,000."},"total_ms":{"type":"object","properties":{"p50":{"type":"integer","nullable":true},"p95":{"type":"integer","nullable":true},"p99":{"type":"integer","nullable":true}}},"decision_breakdown":{"type":"object","properties":{"advance":{"type":"integer"},"reject":{"type":"integer"},"score_review":{"type":"integer"},"skip":{"type":"integer"}}},"parse_success_rate":{"type":"number","nullable":true,"minimum":0,"maximum":1}}}}}}}}}}},"/accounts/{id}/intelligence":{"get":{"summary":"Get company intelligence profile","operationId":"getCompanyIntelligence","tags":["Accounts"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Company intelligence profile","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"account_id":{"type":"string"},"company_name":{"type":"string"},"source":{"type":"string","enum":["intelligence","legacy","mixed","none"]},"tier":{"type":"string","enum":["full","partial","none"]},"is_stale":{"type":"boolean"},"last_synthesised_at":{"type":"string","nullable":true,"format":"date-time"},"generated_at":{"type":"string","format":"date-time"},"reveal":{"type":"object","properties":{"headline":{"type":"string","nullable":true},"summary":{"type":"string","nullable":true}}},"sections":{"type":"object","description":"Seven synthesised sections. Each is {summary, metadata} or null if not yet populated.","properties":{"voice_and_tone":{"type":"object","nullable":true},"hiring_process":{"type":"object","nullable":true},"values_and_culture":{"type":"object","nullable":true},"growth_and_business":{"type":"object","nullable":true},"personal_attributes":{"type":"object","nullable":true},"compensation_posture":{"type":"object","nullable":true},"red_and_green_flags":{"type":"object","nullable":true}}}}}}}}}}}}},"/accounts/{id}/intelligence/refresh":{"post":{"summary":"Refresh company intelligence","operationId":"refreshCompanyIntelligence","tags":["Accounts"],"description":"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.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"202":{"description":"Refresh queued","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"task_id":{"type":"string"},"status":{"type":"string","example":"queued"},"resource_id":{"type":"string","nullable":true},"estimated_seconds":{"type":"integer"},"poll_url":{"type":"string"}}}}}}}},"409":{"description":"Cool-down active (refreshed in the last 10 minutes)."}}}},"/outreach":{"get":{"summary":"List outreach records","operationId":"listOutreach","tags":["Outreach"],"description":"The outreach you have recorded against candidates, newest first.","parameters":[{"name":"candidate_id","in":"query","schema":{"type":"string"},"description":"Only this candidate (cnd_ id)."},{"name":"platform","in":"query","schema":{"type":"string","enum":["email","linkedin","whatsapp"]}},{"name":"status","in":"query","schema":{"type":"string","enum":["draft","sent","replied","no_response"]}}],"responses":{"200":{"description":"Array of outreach records","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Outreach"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}}},"post":{"summary":"Create outreach record","operationId":"createOutreach","tags":["Outreach"],"description":"Record outreach you sent yourself. Lovelio stores it against the candidate; it does not send anything.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["candidate_id","platform","body"],"properties":{"candidate_id":{"type":"string","description":"cnd_ id."},"platform":{"type":"string","enum":["email","linkedin","whatsapp"]},"body":{"type":"string","description":"The message you sent."},"subject":{"type":"string","nullable":true,"description":"Email only."},"job_id":{"type":"string","description":"Optional job_ id the outreach was about."},"status":{"type":"string","enum":["draft","sent","replied","no_response"],"default":"sent"}}}}}},"responses":{"201":{"description":"Outreach record created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Outreach"}}}}}}}}},"/batch":{"post":{"summary":"Execute multiple actions in one request","operationId":"executeBatch","tags":["Batch"],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["op","payload","idempotency_key"],"properties":{"id":{"type":"string","description":"Optional client-supplied correlation ID returned on the matching response op. A server ID is minted if omitted."},"op":{"type":"string","description":"Canonical action name (e.g. move_stage, send_email, add_note). Full list in the ActionName union."},"payload":{"type":"object","description":"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."},"idempotency_key":{"type":"string","description":"Per-operation idempotency key. Replaying the same (company, action, key) within 24h returns the cached result without re-executing."}}}}}},"examples":{"moveAndEmail":{"summary":"Move a candidate and send them an email","value":{"operations":[{"id":"client-op-1","op":"move_stage","payload":{"application_id":"app_abc123","new_stage":"yes"},"idempotency_key":"batch-2026-04-17-001"},{"id":"client-op-2","op":"send_email","payload":{"candidate_id":"cnd_def456","subject":"You're through to the next round","body":"Hi, we loved your application..."},"idempotency_key":"batch-2026-04-17-002"}]}}}}}},"responses":{"200":{"description":"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.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"operations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The client-supplied id (or server-minted if omitted)."},"status":{"type":"string","enum":["succeeded","failed"]},"result":{"type":"object","description":"Present when status=succeeded. Action-specific shape."},"error":{"type":"object","description":"Present when status=failed.","properties":{"type":{"type":"string","enum":["VALIDATION_ERROR","UNKNOWN_ACTION","INSUFFICIENT_SCOPE","PERMISSION_DENIED","BULK_CONFIRMATION_REQUIRED","RESOURCE_NOT_FOUND","CONFLICT","PREMIUM_REQUIRED","ACTION_FAILED"],"description":"What went wrong with this operation. ACTION_FAILED means a fault on our side - retry it. Every other code means the operation itself was refused, so retrying it unchanged will fail again."},"message":{"type":"string"}}}}}}}},"meta":{"$ref":"#/components/schemas/Meta"}}},"examples":{"mixedResult":{"summary":"One op succeeded, one failed","value":{"success":true,"data":{"operations":[{"id":"client-op-1","status":"succeeded","result":{"id":"app_abc123","message":"Moved to yes"}},{"id":"client-op-2","status":"failed","error":{"type":"RESOURCE_NOT_FOUND","message":"Candidate not found"}}]},"meta":{"request_id":"req_01HZABCDEF"}}}}}}},"400":{"description":"Batch exceeds 100 operations (error code: BATCH_TOO_LARGE)."},"422":{"description":"Malformed batch body (missing operations, invalid JSON)."},"429":{"description":"Rate limit exceeded. The batch is rejected without partially consuming rate limit."}}}},"/distribution/boards":{"get":{"summary":"List job boards","operationId":"listDistributionBoards","tags":["Distribution"],"description":"The catalogue of integrated job boards available to connect, with your monthly posting allowance per board (limit, used, remaining for the current month).","responses":{"200":{"description":"Array of boards","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/DistributionBoard"}}}}}}}}}},"/distribution/rules":{"get":{"summary":"List distribution rules","operationId":"listDistributionRules","tags":["Distribution"],"parameters":[{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"description":"Array of rules","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/DistributionRule"}}}}}}}}},"post":{"summary":"Create a distribution rule","operationId":"createDistributionRule","tags":["Distribution"],"description":"Route matching jobs to boards. Free/mixed boards auto-publish; paid boards await confirmation.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","target_board_slugs"],"properties":{"name":{"type":"string"},"target_board_slugs":{"type":"array","items":{"type":"string"}},"filter":{"type":"object","description":"See the DistributionRule schema. country is an ISO 3166 alpha-2 code (a country name is normalised to one on write).","properties":{"country":{"type":"string","example":"AU"},"category":{"type":"string"},"job_type":{"type":"string","enum":["Permanent","Contract","Temporary"]},"team_id":{"type":"string"}}},"active":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Created rule","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/DistributionRule"}}}}}}}}},"/distribution/rules/{id}":{"patch":{"summary":"Update a distribution rule","operationId":"updateDistributionRule","tags":["Distribution"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"patch":{"type":"object","properties":{"name":{"type":"string"},"target_board_slugs":{"type":"array","items":{"type":"string"}},"filter":{"type":"object"},"active":{"type":"boolean"}}}}}}}},"responses":{"200":{"description":"Updated rule","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/DistributionRule"}}}}}}}},"delete":{"summary":"Delete a distribution rule","operationId":"deleteDistributionRule","tags":["Distribution"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"}}}},"/distribution/postings":{"get":{"summary":"List job board postings","operationId":"listDistributionPostings","tags":["Distribution"],"description":"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.","parameters":[{"name":"job_id","in":"query","schema":{"type":"string"},"description":"Filter to one job"},{"name":"job_ad_id","in":"query","schema":{"type":"string"},"description":"Filter to one job ad"},{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"description":"Array of postings","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/JobBoardPosting"}}}}}}}}}},"/distribution/publish":{"post":{"summary":"Distribute a job to boards","operationId":"distributeJob","tags":["Distribution"],"description":"With board_slugs, publishes asynchronously to those boards (202 + task). Without, evaluates the company distribution rules and returns the resolved targets.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["job_id"],"properties":{"job_id":{"type":"string"},"board_slugs":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"Rule-resolved targets","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"job_id":{"type":"string"},"auto":{"type":"array","items":{"type":"string"}},"needs_confirmation":{"type":"array","items":{"type":"string"}}}}}}}}},"202":{"description":"Async task accepted"}}}}},"tags":[{"name":"System","description":"Health and status"},{"name":"Accounts","description":"Account and API key management"},{"name":"Jobs","description":"Job requisition management with AI enrichment"},{"name":"Job Ads","description":"Public job postings and career pages"},{"name":"Candidates","description":"Candidate profiles and talent database"},{"name":"Candidate Imports","description":"Bulk CV import - migrate thousands of CVs into the talent bank"},{"name":"Applications","description":"Application pipeline with AI assessment"},{"name":"Interviews","description":"Interview scheduling and feedback"},{"name":"Submissions","description":"Client-facing shortlists with a tokenised review link and a live feedback loop"},{"name":"Placements","description":"The record of the win: fee, start date, guarantee lifecycle"},{"name":"Quotas","description":"Quota plan, periods and the recognition board: credit-share money against targets for the agency, teams and consultants"},{"name":"Marketplace","description":"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."},{"name":"Webhooks","description":"Event subscriptions with HMAC-SHA256 signing"},{"name":"Automations","description":"Workflow automation rules and triggers"},{"name":"Batch","description":"Execute multiple canonical actions in a single request with per-op idempotency"},{"name":"Activities","description":"Audit log and activity tracking"},{"name":"Documents","description":"Files attached to any record - CVs, contracts, briefs, right-to-work scans"},{"name":"Tasks","description":"Async operation polling"},{"name":"Outreach","description":"AI-powered candidate outreach"},{"name":"Talent Pools","description":"Talent pool membership and candidate rediscovery"},{"name":"Review Queue","description":"Items Lovelio cannot decide on its own and needs a recruiter to handle"},{"name":"Forms","description":"Form templates, phone screen execution, and reference check delivery"},{"name":"Referees","description":"Candidate referee management for reference checks"},{"name":"Integrations","description":"Third-party integrations (Slack, Calendar)"},{"name":"Chat Integrations","description":"AI chatbot platform connections (Slack, Teams, WhatsApp, Google Chat) and user identity mappings"},{"name":"Analytics","description":"Hiring dashboard, pipeline funnel analysis, and KPI metrics"},{"name":"Calendar","description":"Shared calendar events for interviews, phone screens, reminders, deadlines, and meetings"},{"name":"Scheduled Emails","description":"Superhuman-style scheduled candidate email sends. Draft, schedule, edit, cancel, or send-now before delivery."},{"name":"Workflow Rules","description":"Per-company configuration for AI thresholds, HM deadlines, and follow-up cadences"},{"name":"Email Templates","description":"Canonical transactional email templates and per-company AI instruction overrides"},{"name":"Stages","description":"Canonical pipeline stage reference (labels, transitions, terminal flags)"},{"name":"Distribution","description":"Job board catalogue, posting rules, multi-board distribution, and the posting ledger"}]}