{"openapi":"3.1.0","info":{"title":"Lovelio API","version":"1.0.0","description":"Recruitment automation API. Create jobs, manage candidates, run ad-hoc actions, integrate with your tools.\n\n**1.0.0 (2026-08-18):** Initial public API baseline. Covers the complete recruiter workflow across clients, jobs, candidates, applications, assessments, screens, submissions, interviews, references, placements, search, email, webhooks, reporting, and automation actions.","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","keys:read":"Read your recruiter DNA profile","keys:write":"Write your recruiter DNA profile","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"},"provider":{"type":"string","nullable":true,"description":"The integration provider serving this board, when it comes from a provider catalogue."},"regions":{"type":"array","items":{"type":"string"}},"industries":{"type":"array","items":{"type":"string"}},"categories":{"type":"array","items":{"type":"string"}},"pricing":{"type":"string","enum":["free","paid","mixed"],"nullable":true,"description":"What Lovelio states this board costs. null means Lovelio does not price it - the board bills on your own contract with it, so nothing is claimed. An unpriced board never auto-publishes from a rule: like a paid board, it is parked for confirmation."},"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"},"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}}},"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."},"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."},"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 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},"phone":{"type":"string","nullable":true,"description":"The company main phone or switchboard. Contact phone numbers live on client contacts."},"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"},"stage":{"type":"string","description":"Current pipeline stage of the application."},"status":{"type":"string","deprecated":true,"description":"Deprecated alias for stage. Read stage instead."}}},"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","nullable":true,"minimum":0,"maximum":100,"description":"Client Fit (alignment with the client's DNA). Null when the job's client has no completed Client DNA, or when the job fit landed at or below the company's job-fit reject line - Client Fit is only scored for candidates above that line, and fills in when a consultant first opens the assessment (or via the complete_assessment action)."},"overall_recommendation":{"type":"string","enum":["strong_hire","hire","hold","talent_pool","reject"]},"summary":{"type":"string","nullable":true,"description":"Consultant summary. Empty string while deferred: summaries are written when the job fit clears the company's reject line, and otherwise on the first consultant look (or via the complete_assessment action)."},"alternative_teams":{"type":"array","items":{"type":"string"}},"alternative_roles":{"type":"array","items":{"type":"string"}},"confidence_score":{"type":"number","nullable":true,"minimum":0,"maximum":1,"description":"How complete and internally consistent the evidence was."},"confidence_factors":{"type":"array","items":{"type":"string"},"description":"Evidence factors that raised or lowered confidence."},"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}}},"phone_screen_score":{"type":"number","nullable":true,"minimum":0,"maximum":100},"parsed_profile":{"$ref":"#/components/schemas/ParsedProfile"},"notes":{"type":"string","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","client_question_unanswered","spec_wants_more"]},"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 submission 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."},"headline":{"type":"string","nullable":true,"description":"The headline at the top of the client review page, in the consultant's own words. Null means the generated default (\"Hi {first name}, {n} candidates for your review.\")."},"intro_body":{"type":"string","nullable":true,"description":"What the consultant wrote above the candidates, in their own words, shown in the email and on the review page. Null means the generated default (\"I am delighted to submit {first names} for your role of {job title}. Please see specific details below...\" signed off by the consultant)."},"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]},"wants_more_candidates":{"type":"boolean","nullable":true,"description":"On a pass: whether the client wants to see more candidates for the role. Asked as a required yes/no on the review portal. Null when the verdict came through a door that never asked (an email reply, a consultant-keyed phone verdict). Also carried on the submission.responded webhook payload."},"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 as an email attachment."},"terms_url":{"type":"string","nullable":true,"description":"The client's living terms of business page, linked in every float email - the introduction record. Null only for floats sent before 2026-08-30."},"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."},"wants_more":{"type":"boolean","nullable":true,"description":"Does this client want to see more people? Answered by a required yes/no on either response dialog, or set true when they send a brief through the standing box on the profile page. Null means no door has asked them."},"wants_more_note":{"type":"string","nullable":true,"description":"The client's brief in their own words (\"two more BDMs before Christmas\"). Appended across sends and never rewritten. Null when they only answered the yes/no."},"wants_more_at":{"type":"string","format":"date-time","nullable":true,"description":"When they last asked for more people."},"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."},"client_confirmed_at":{"type":"string","format":"date-time","nullable":true,"description":"Set when a client contact confirms attendance from their own confirmation link. Independent of candidate_confirmed_at - either can be set without the other. Null = they have not told us. Read-only."},"duration_minutes":{"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"},"account_id":{"type":"string","example":"acc_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","description":"The 202 acknowledgement of an async operation. Poll poll_url, which returns a TaskStatus.","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"}}},"TaskStatus":{"type":"object","description":"What GET /tasks/{id} returns while polling. Richer than the 202 Task: it names the operation and carries the result or error once the task finishes. It has no estimated_seconds.","properties":{"task_id":{"type":"string","example":"tsk_550e8400e29b41d4a716446655440000"},"status":{"type":"string","enum":["queued","processing","completed","failed"]},"operation":{"type":"string","enum":["create_job","describe_job","describe_email","publish_job_ad","distribute_job","enrich_account","parse_candidate_cv","assess_application","schedule_interview"]},"resource_id":{"type":"string","nullable":true},"result":{"type":"object","nullable":true,"description":"Operation-specific result payload once status is completed."},"error":{"type":"object","nullable":true,"description":"Error detail once status is failed."},"started_at":{"type":"string","format":"date-time","nullable":true},"completed_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"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","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"}}},"FormInstanceQuestion":{"type":"object","required":["id","question_text","question_type","required"],"properties":{"id":{"type":"string","description":"fiq_ id."},"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},"ai_generated":{"type":"boolean","nullable":true},"required":{"type":"boolean"},"minutes":{"type":"integer","nullable":true,"description":"Estimated minutes for this reference question."},"why_asked":{"type":"string","nullable":true,"description":"Why Lovelio included this question."}}},"FormResponse":{"type":"object","required":["id","question_id"],"properties":{"id":{"type":"string","description":"frs_ id."},"question_id":{"type":"string","description":"fiq_ id."},"response_text":{"type":"string","nullable":true},"response_rating":{"type":"number","nullable":true},"response_boolean":{"type":"boolean","nullable":true},"response_choice":{"type":"string","nullable":true},"ai_suggested_score":{"type":"number","nullable":true},"final_score":{"type":"number","nullable":true},"flag":{"type":"string","nullable":true}}},"FormInstance":{"type":"object","required":["id","instance_type","status"],"properties":{"id":{"type":"string","example":"fin_550e8400e29b41d4a716446655440000"},"form_id":{"type":"string"},"instance_type":{"type":"string","enum":["screening","reference","interview"]},"status":{"type":"string","enum":["draft","sent","opened","started","completed"]},"job_application_id":{"type":"string","nullable":true},"referee_id":{"type":"string","nullable":true},"candidate_referee_id":{"type":"string","nullable":true,"description":"The public ref_ record when this is a reference."},"interview_id":{"type":"string","nullable":true},"channel":{"type":"string","nullable":true,"enum":["email","phone",null]},"taken_by":{"type":"string","nullable":true,"description":"usr_ id of the consultant taking a phone reference."},"call_started_at":{"type":"string","format":"date-time","nullable":true},"call_ended_at":{"type":"string","format":"date-time","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"},"questions":{"type":"array","items":{"$ref":"#/components/schemas/FormInstanceQuestion"}},"responses":{"type":"array","items":{"$ref":"#/components/schemas/FormResponse"}}}},"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","nullable":true},"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"}}},"ClientDna":{"type":"object","required":["id","status","hiring_dna_version"],"properties":{"id":{"type":"string","description":"dna_ id."},"status":{"type":"string"},"final_reflection":{"type":"string","nullable":true},"reanalysis_status":{"type":"string","nullable":true},"reanalysis_error":{"type":"string","nullable":true},"last_reanalysed_at":{"type":"string","format":"date-time","nullable":true},"hiring_dna_version":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"InterviewContext":{"type":"object","required":["id","candidate","job","scorecards","consolidated","nextMoves"],"properties":{"id":{"type":"string","description":"ivw_ id."},"scheduledAt":{"type":"string","format":"date-time","nullable":true},"whenLabel":{"type":"string"},"durationMinutes":{"type":"integer","nullable":true},"type":{"type":"string","nullable":true},"round":{"type":"integer"},"flavour":{"type":"string","enum":["internal","client"]},"outcome":{"type":"string"},"location":{"type":"string","nullable":true},"candidate":{"type":"object","properties":{"id":{"type":"string","nullable":true},"name":{"type":"string"},"photoUrl":{"type":"string","nullable":true},"email":{"type":"string","nullable":true}}},"job":{"type":"object","properties":{"id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"clientName":{"type":"string","nullable":true},"applicationId":{"type":"string","nullable":true},"applicationStage":{"type":"string","nullable":true},"refereeMinRequired":{"type":"integer","description":"How many references this application asked the candidate for. 2 when the application never set one."},"clientContactNames":{"type":"array","items":{"type":"string"}},"clientContact":{"type":"object","nullable":true,"description":"The first client contact on the invite with a record behind them - the person the emailed feedback form goes to. Null on internal interviews.","properties":{"id":{"type":"string","description":"cct_ id."},"name":{"type":"string","nullable":true}}},"prepUrl":{"type":"string","nullable":true,"description":"The client interviewer feedback link. Legacy name - it carries no prep."},"scorecards":{"type":"array","items":{"type":"object","properties":{"instanceId":{"type":"string","nullable":true},"prepUrl":{"type":"string","nullable":true},"person":{"type":"object"},"status":{"type":"string","enum":["not_started","in_progress","submitted"]},"submittedAt":{"type":"string","format":"date-time","nullable":true},"recommendation":{"type":"string","nullable":true},"overall":{"type":"number","nullable":true},"areas":{"type":"array","items":{"type":"object"}},"feedback":{"type":"string","nullable":true}}}},"consolidated":{"type":"object","description":"Deterministic roll-up of all submitted interviewer cards."},"nextMoves":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string"},"label":{"type":"string"}}}},"feedbackCalls":{"type":"object","description":"Where the two post-interview debriefs are up to on a client-flavour interview, phone and emailed channels combined. Both 'none' on internal interviews. 'awaiting_reply' means the emailed form is out and unanswered.","properties":{"client":{"type":"string","enum":["none","started","awaiting_reply","logged"]},"candidate":{"type":"string","enum":["none","started","awaiting_reply","logged"]}}}}},"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","forms"]},"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"},"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."},"idempotencyKey":{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"},"description":"Unique key for this mutation. Repeating the same request with the same key within 24 hours returns the saved response without running it again."}},"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}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"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 optionally define where candidates must be based.","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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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."},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"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 optionally define where candidates must be based.","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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/jobs/{id}/close":{"post":{"summary":"Close or pause job","operationId":"closeJob","tags":["Jobs"],"description":"Records the close reason for Recruiter DNA, takes every live job ad and board posting offline, and starts the silver-medalist sweep for terminal closes. on_hold and budget_frozen pause the job and do not run the sweep.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","enum":["filled","closed","cancelled","on_hold","budget_frozen"],"default":"cancelled"}}}}}},"responses":{"200":{"description":"Job closed or paused","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"id":{"type":"string","description":"job_ id."},"status":{"type":"string","enum":["filled","closed","on_hold"]},"reason":{"type":"string","enum":["filled","closed","cancelled","on_hold","budget_frozen"]},"closed_at":{"type":"string","format":"date-time"}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/job-ads/{id}/expire":{"post":{"summary":"Expire job ad","operationId":"expireJobAd","tags":["Job Ads"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"responses":{"200":{"description":"Expired","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]},"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 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 + Client DNA."},"phone":{"type":"string","description":"Company main phone or switchboard."},"industry":{"type":"string"},"description":{"type":"string"},"locations":{"type":"array","items":{"type":"string"},"description":"Office locations as City, Country."},"address":{"type":"string"},"contacts":{"type":"array","description":"Contacts to create with the client in the same resumable action.","items":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string"},"title":{"type":"string"},"notes":{"type":"string"},"is_primary":{"type":"boolean"}},"required":["name"]}},"operation_key":{"type":"string","description":"Optional caller key for safely resuming a partially completed client-and-contacts create."},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:write"]}],"x-required-scopes":["clients:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]},"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 + Client DNA, same as providing it at create time (enrichment_status returns \"pending\")."},"phone":{"type":"string","nullable":true,"description":"Company main phone or switchboard."},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:write"]}],"x-required-scopes":["clients:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:write"]}],"x-required-scopes":["clients:write"]}},"/clients/{id}/dna":{"get":{"summary":"Get Client DNA","operationId":"getClientDna","tags":["Clients"],"description":"Returns the Client DNA that assessments, phone screens, interview forms and job ads use: the reflection, five traits and append-only refinement notes.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"cli_ id."}],"responses":{"200":{"description":"Client DNA, or dna null while it is not available.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"dna":{"allOf":[{"$ref":"#/components/schemas/ClientDna"}],"nullable":true},"traits":{"type":"array","items":{"type":"object"}},"addendums":{"type":"array","items":{"type":"object"}}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]},"post":{"summary":"Add Client DNA note","operationId":"addClientDnaNote","tags":["Clients"],"description":"Appends a meeting, call or text note and queues the shared Client DNA reanalysis. Existing notes never change.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"cli_ id."},{"$ref":"#/components/parameters/idempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string"},"source":{"type":"string","enum":["text","voice","meeting"],"default":"text"}}}}}},"responses":{"201":{"description":"Note stored and reanalysis queued.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"addendum_id":{"type":"string"},"status":{"type":"string","enum":["reanalysing"]}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:write"]}],"x-required-scopes":["clients:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"],"description":"Pagination: Returns the complete contact list for one client. No after cursor is accepted."},"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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:write"]}],"x-required-scopes":["clients:write"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:write"]}],"x-required-scopes":["clients:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:write"]}],"x-required-scopes":["clients:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["submissions:read"]}],"x-required-scopes":["submissions:read"]},"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. Plain text with light formatting: blank lines start a new paragraph, lines starting \"- \" become a bullet list, \"1. \" a numbered list, and **word** renders bold in the email and on the review page."}}}},"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 \"Candidates for the role of {job title}\", singular for a submission of one. Stored on the submission, so a resend repeats it rather than generating a different line."},"headline":{"type":"string","description":"The headline at the top of the client review page - the largest line the contact reads. Defaults to \"Hi {first name}, {n} candidates for your review.\". Stored on the submission, so a resend shows the page the client first read."},"intro_body":{"type":"string","description":"Everything said above the candidates, in the consultant's own words, shown in the email and on the review page. Defaults to \"I am delighted to submit {first names} for your role of {job title}. Please see specific details below. I look forward to your feedback when you have time to review.\" signed off by the consultant. Stored on the submission, so a resend repeats what the client first read. Plain text with light formatting: blank lines start a new paragraph, lines starting \"- \" become a bullet list, \"1. \" a numbered list, and **word** renders bold."},"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"}}}]}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["submissions:write"]}],"x-required-scopes":["submissions:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["submissions:read"]}],"x-required-scopes":["submissions:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["submissions:read"]}],"x-required-scopes":["submissions:read"]},"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"}}}]}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["submissions:write"]}],"x-required-scopes":["submissions:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["submissions:read"]}],"x-required-scopes":["submissions:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"]},"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)."}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"],"description":"Pagination: Returns one computed duplicate-review report, not a stored row collection. No after cursor is accepted."}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:read"]}],"x-required-scopes":["applications:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:read"]}],"x-required-scopes":["applications:read"]},"patch":{"summary":"Update application","operationId":"updateApplication","tags":["Applications"],"description":"Update non-stage fields (note). The `stage`/`status` fields are not accepted. Returns 410 if either is present; use POST /applications/{id}/stage for a manual move or the reject_candidate / withdraw_application batch actions.","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}/stage or POST /batch."}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"]}},"/applications/{id}/assessment":{"post":{"summary":"Re-assess application","operationId":"reassessApplication","tags":["Applications"],"description":"Queues a fresh assessment after the candidate, job requirements or Client DNA changed. The existing assessment remains readable until the new result replaces it.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"app_ id."},{"$ref":"#/components/parameters/idempotencyKey"}],"responses":{"202":{"description":"Assessment queued.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","required":["application_id","status"],"properties":{"application_id":{"type":"string"},"status":{"type":"string","enum":["processing"]}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"]}},"/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"}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]},"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 NEVER moves the application: how the interview went and what happens next are separate decisions, and the second one is a person's. Move the candidate with POST /applications/{id}/stage once that decision is made."},"status":{"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/interviews/{id}/context":{"get":{"summary":"Get interview preparation and debrief","operationId":"getInterviewContext","tags":["Interviews"],"description":"The complete interview read: attendees and confirmation, the 30-second preparation digest, one scorecard per interviewer, the deterministic consolidated verdict, and the legal next actions. Recording an outcome never moves the application stage.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"ivw_ id."}],"responses":{"200":{"description":"Interview context.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/InterviewContext"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]}},"/interviews/{id}/cancel":{"post":{"summary":"Cancel interview","operationId":"cancelInterview","tags":["Interviews"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Interview"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/interviews/{id}/scorecard":{"post":{"summary":"Submit interview scorecard","operationId":"submitScorecard","tags":["Interviews"],"description":"Compatibility door for submitting the authenticated interviewer's scorecard. Scores map section names or section ids to a 1-5 score. Lovelio writes through the same per-interviewer form used by the product, consolidates all interviewer cards, records the interview outcome, and never moves the application stage. Requires interviews:write.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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":{"type":"object","required":["interview_id","form_instance_id","recommendation","status"],"properties":{"interview_id":{"type":"string","description":"ivw_ id."},"form_instance_id":{"type":"string","description":"fin_ id for this interviewer's completed card."},"recommendation":{"type":"string","enum":["strong_hire","hire","no_hire","strong_no_hire"]},"outcome":{"type":"string","nullable":true,"enum":["Passed","Failed",null]},"status":{"type":"string","enum":["completed"]}}}}}}}},"422":{"description":"Missing or invalid recommendation."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/placements":{"get":{"summary":"List placements","operationId":"listPlacements","tags":["Placements"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["placements:read"]}],"x-required-scopes":["placements:read"]},"post":{"summary":"Create 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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["placements:write"]}],"x-required-scopes":["placements:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["placements:read"]}],"x-required-scopes":["placements:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["placements:write"]}],"x-required-scopes":["placements:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["quotas:read"]}],"x-required-scopes":["quotas:read"]}},"/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.\n\nPagination: Returns the newest bounded period window selected by limit; agencies ordinarily create at most 12 periods a year. No after cursor is accepted.","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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["quotas:read"]}],"x-required-scopes":["quotas:read"]}},"/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"}}}}}}]}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["quotas:read"]}],"x-required-scopes":["quotas:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:read"]}],"x-required-scopes":["marketplace:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:write"]}],"x-required-scopes":["marketplace:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:read"]}],"x-required-scopes":["marketplace:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:write"]}],"x-required-scopes":["marketplace:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:read"]}],"x-required-scopes":["marketplace:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:write"]}],"x-required-scopes":["marketplace:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:read"]}],"x-required-scopes":["marketplace:read"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:write"]}],"x-required-scopes":["marketplace:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:read"]}],"x-required-scopes":["marketplace:read"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:read"]}],"x-required-scopes":["marketplace:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["marketplace:write"]}],"x-required-scopes":["marketplace:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]}},"/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"}}}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["clients:read"]}],"x-required-scopes":["clients:read"]}},"/webhooks":{"get":{"summary":"List webhooks","operationId":"listWebhooks","tags":["Webhooks"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"}],"responses":{"200":{"description":"Paginated 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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:read"]}],"x-required-scopes":["webhooks:read"]},"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":{"allOf":[{"$ref":"#/components/schemas/Webhook"},{"type":"object","properties":{"signing_secret":{"type":"string","description":"HMAC-SHA256 signing secret (whsec_ prefix). Returned only once on creation - it cannot be retrieved again."}}}]}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:write"]}],"x-required-scopes":["webhooks:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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.\n\nPagination: Returns the complete static webhook event catalogue. No after cursor is accepted.","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":["*"]}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:read"]}],"x-required-scopes":["webhooks:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:read"]}],"x-required-scopes":["webhooks:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]},"delete":{"summary":"Delete webhook","operationId":"deleteWebhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]}},"/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"},"data":{"allOf":[{"$ref":"#/components/schemas/Webhook"},{"type":"object","properties":{"signing_secret":{"type":"string","description":"New HMAC-SHA256 signing secret (whsec_ prefix). Returned only once - store it immediately; the old secret stops verifying."}}}]}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]}},"/webhooks/{id}/test":{"post":{"summary":"Test webhook","operationId":"testWebhook","tags":["Webhooks"],"description":"Sends a signed test event to the webhook URL synchronously and reports what happened. No delivery record is created.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Test delivery attempted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether the endpoint answered 2xx."},"event_sent":{"type":"string","description":"The event name used for the test payload."},"url":{"type":"string"},"response_code":{"type":"integer","nullable":true,"description":"HTTP status from the endpoint. null when the request never completed."},"latency_ms":{"type":"integer"},"error":{"type":"string","nullable":true,"description":"Network or safety error when the request could not be delivered."}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]}},"/webhooks/{id}/deliveries":{"get":{"summary":"List webhook deliveries","operationId":"listDeliveries","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"status","in":"query","schema":{"type":"string"}},{"name":"event","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated delivery history. Delivery ids use the dlv_ prefix.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:read"]}],"x-required-scopes":["webhooks:read"]}},"/webhooks/{id}/deliveries/{deliveryId}/replay":{"post":{"summary":"Replay webhook delivery","operationId":"replayDelivery","tags":["Webhooks"],"description":"Queues a fresh delivery of the original payload. The response is the new delivery reference, not the delivery outcome - watch GET /webhooks/{id}/deliveries for the result.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"deliveryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Replay queued","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"delivery_id":{"type":"string","description":"The NEW delivery created for this replay (dlv_)."},"status":{"type":"string","enum":["queued"]},"replayed_from":{"type":"string","description":"The original delivery id."}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["activities:read"]}],"x-required-scopes":["activities:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["activities:write"]}],"x-required-scopes":["activities:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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. This is a bounded per-record collection and returns the full list; meta.has_more is always false.\n\nPagination: Returns the complete document list for one record. No after cursor is accepted.","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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["documents:read"]}],"x-required-scopes":["documents:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["documents:write"]}],"x-required-scopes":["documents:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["documents:read"]}],"x-required-scopes":["documents:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["documents:write"]}],"x-required-scopes":["documents:write"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["documents:read"]}],"x-required-scopes":["documents:read"]}},"/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/TaskStatus"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":[]}],"x-required-scopes":[]}},"/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"}}}}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/talent-pools":{"get":{"summary":"List talent pools","operationId":"listTalentPools","tags":["Talent Pools"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"}],"responses":{"200":{"description":"Paginated 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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:read"]}],"x-required-scopes":["talent_pools:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:write"]}],"x-required-scopes":["talent_pools:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:read"]}],"x-required-scopes":["talent_pools:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:write"]}],"x-required-scopes":["talent_pools:write"]},"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"}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:write"]}],"x-required-scopes":["talent_pools:write"]}},"/talent-pools/{id}/members":{"get":{"summary":"List pool members","operationId":"listTalentPoolMembers","tags":["Talent Pools"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"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":"Paginated 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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:read"]}],"x-required-scopes":["talent_pools:read"]},"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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:write"]}],"x-required-scopes":["talent_pools:write"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:write"]}],"x-required-scopes":["talent_pools:write"]}},"/talent-pools/{id}/requests":{"get":{"summary":"List join requests","operationId":"listTalentPoolJoinRequests","tags":["Talent Pools"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","approved","declined"]}}],"responses":{"200":{"description":"Paginated join requests","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"type":"object"}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:read"]}],"x-required-scopes":["talent_pools:read"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"responses":{"200":{"description":"Approved"}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:write"]}],"x-required-scopes":["talent_pools:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"responses":{"200":{"description":"Declined"}},"security":[{"bearerAuth":[]},{"appOAuth2":["talent_pools:write"]}],"x-required-scopes":["talent_pools:write"]}},"/public/talent-pools/{employerSlug}/{poolSlug}/join":{"post":{"summary":"Ask to join a talent pool","operationId":"joinTalentPool","tags":["Talent Pools"],"description":"No authentication required. Stores a PENDING join request only - no candidate record is created until a recruiter approves. Asking again with the same email is a friendly no-op (a fresh 200), so the endpoint never leaks whether someone already asked. Rate limited per IP and pool.","security":[],"parameters":[{"name":"employerSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"poolSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["full_name","email","consent"],"properties":{"full_name":{"type":"string"},"email":{"type":"string","format":"email"},"consent":{"type":"string","enum":["true"],"description":"Must be the string \"true\" - the person consents to the agency holding their details."},"linkedin_url":{"type":"string","nullable":true},"cv":{"type":"string","format":"binary","description":"Optional CV file: PDF or Word, 10MB max."}}}}}},"responses":{"200":{"description":"Request received","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"received":{"type":"boolean"}}},"meta":{"$ref":"#/components/schemas/Meta"}}}}}},"404":{"description":"Unknown employer or pool, or the pool is not published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Missing or invalid field (error.field names it)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited - read Retry-After","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/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"}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:read"]}],"x-required-scopes":["emails:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:read"]}],"x-required-scopes":["applications:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:read"]}],"x-required-scopes":["integrations:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:write"]}],"x-required-scopes":["integrations:write"]},"delete":{"summary":"Disconnect Slack","operationId":"deleteSlackIntegration","tags":["Integrations"],"responses":{"204":{"description":"Disconnected"}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:write"]}],"x-required-scopes":["integrations:write"]}},"/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.\n\nPagination: Returns the complete configured chat-connection list for one agency. No after cursor is accepted.","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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:read"]}],"x-required-scopes":["integrations:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:write"]}],"x-required-scopes":["integrations:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:write"]}],"x-required-scopes":["integrations:write"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:write"]}],"x-required-scopes":["integrations:write"]}},"/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.\n\nPagination: Returns the complete user-mapping list for one chat connection. No after cursor is accepted.","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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:read"]}],"x-required-scopes":["integrations:read"]},"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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:write"]}],"x-required-scopes":["integrations:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["integrations:write"]}],"x-required-scopes":["integrations:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/applications/{id}/stage":{"post":{"summary":"Transition application stage","operationId":"transitionApplicationStage","tags":["Applications"],"description":"Manually moves an application to any canonical pipeline stage. There is no transition graph. Client-side stages still require real submission evidence: send the candidate to the client through a submission before moving them to submitted, client_interview, references, or offer. The API records the source as api and attributes a person-bound key to its acting user; callers cannot spoof another channel.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Application external ID (app_ prefix)"},{"$ref":"#/components/parameters/idempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["stage"],"properties":{"stage":{"type":"string","enum":["funnel","maybe","yes","no","screen","submitted","client_interview","references","offer","placed","rejected","withdrew"]},"reason":{"type":"string","description":"Optional audit note explaining the manual move."}}}}}},"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 a client-side stage requested before submission evidence exists."}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"]}},"/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","client_question_unanswered","spec_wants_more"]}},{"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."},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"}],"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}}}}}}}},"422":{"description":"Invalid stage, or a client-side stage was requested before the candidate was submitted to the client."}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:read"]}],"x-required-scopes":["applications:read"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["applications:write"]}],"x-required-scopes":["applications:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:read"]}],"x-required-scopes":["forms:read"],"description":"Pagination: Returns the complete form-template configuration for one agency. No after cursor is accepted."},"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","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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:read"]}],"x-required-scopes":["forms:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"]}},"/forms/{id}/questions":{"post":{"summary":"Add question to form","operationId":"addFormQuestion","tags":["Forms"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"]}},"/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","interview"]}},{"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"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"}],"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:read"]}],"x-required-scopes":["forms:read"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:read"]}],"x-required-scopes":["forms:read"]}},"/form-instances/{id}/submit":{"post":{"summary":"Save or complete a form instance","operationId":"submitFormInstance","tags":["Forms"],"description":"Writes responses through the product completion core. A screening form is scored, stores application.phone_screen_score and moves yes to screen without letting the score decide the next stage. A phone reference completes through the same reference set and summary path as an emailed response. Pass draft true to autosave a phone reference without completing it.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"fin_ id."},{"$ref":"#/components/parameters/idempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["responses"],"properties":{"draft":{"type":"boolean","default":false},"responses":{"type":"array","items":{"type":"object","required":["question_id"],"properties":{"question_id":{"type":"string","description":"fiq_ id."},"response_text":{"type":"string","nullable":true},"response_rating":{"type":"number","nullable":true},"response_boolean":{"type":"boolean","nullable":true},"response_choice":{"type":"string","nullable":true},"ai_suggested_score":{"type":"number","nullable":true},"final_score":{"type":"number","nullable":true},"flag":{"type":"string","nullable":true}}}}}}}}},"responses":{"200":{"description":"Draft saved, reference completed or screening scored. data.kind identifies the result shape.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"kind":{"type":"string","enum":["draft","reference","screening"]},"saved":{"type":"boolean"},"completed":{"type":"boolean"},"completed_count":{"type":"integer"},"expected":{"type":"integer"},"set_complete":{"type":"boolean"},"score":{"type":"number","nullable":true},"auto_advance":{"type":"string","nullable":true,"enum":["shortlist","reject_prompt",null]},"instance_id":{"type":"string"}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"]}},"/form-instances/screening":{"post":{"summary":"Prepare screening instance","operationId":"createScreeningInstance","tags":["Forms"],"description":"Loads, generates or regenerates the application phone screen through the shared product core. mode is a per-screen choice: ai writes 6-8 candidate-aware questions from the CV, assessment, Client DNA, job focus and brief; template copies the named screening form verbatim. Omit mode only to load an existing instance. Regeneration is refused after any response exists.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["application_id"],"properties":{"application_id":{"type":"string"},"mode":{"type":"string","enum":["ai","template"]},"template_form_id":{"type":"string","description":"frm_ id used when mode is template."},"regenerate":{"type":"boolean","default":false},"brief":{"type":"string","maxLength":4000,"description":"Consultant guidance for AI question generation."}}}}}},"responses":{"201":{"description":"Screen ready with questions and existing responses.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"instance":{"$ref":"#/components/schemas/FormInstance"},"questions":{"type":"array","items":{"$ref":"#/components/schemas/FormInstanceQuestion"}},"responses":{"type":"array","items":{"$ref":"#/components/schemas/FormResponse"}},"candidate_name":{"type":"string","nullable":true}}}}}}}},"409":{"description":"Regeneration refused because answers exist."},"422":{"description":"No mode for a new screen, no usable template, or insufficient candidate information."}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/form-instances/reference":{"post":{"summary":"Create reference instance","operationId":"createReferenceInstance","tags":["Forms"],"description":"Creates a reference check form instance for a referee, ready to send. referee_id is the ref_ id returned by GET or POST /candidates/{id}/referees; Lovelio bridges that candidate referee into the questionnaire store automatically. Legacy ref_ ids from older integrations remain accepted. Unless the account uses its own template, Lovelio writes the questions from the assessment, interviews, client feedback and consultant notes, sized to a 10 to 15 minute call. The response says which source was used and how long the set should take.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["application_id","referee_id"],"properties":{"application_id":{"type":"string"},"referee_id":{"type":"string","description":"ref_ id returned by the candidate referee endpoints."},"form_id":{"type":"string","description":"Optional frm_ template."},"channel":{"type":"string","enum":["email","phone"],"default":"email"},"mode":{"type":"string","enum":["ai","template"],"description":"Question source. Omit for the product default."}}}}}},"responses":{"201":{"description":"Reference instance created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"instance_id":{"type":"string"},"form_id":{"type":"string"},"referee_id":{"type":"string"},"question_count":{"type":"integer"},"question_source":{"type":"string","enum":["ai","template"],"description":"Where the questions came from. \"template\" also means Lovelio tried and fell back."},"estimated_minutes":{"type":"integer","description":"How long the set should take on a call."},"status":{"type":"string"},"channel":{"type":"string","enum":["email","phone"]}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"responses":{"200":{"description":"Form sent","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"instance_id":{"type":"string","description":"fin_ id."},"sent_to":{"type":"string","format":"email"},"status":{"type":"string","enum":["sent"]}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["forms:write"]}],"x-required-scopes":["forms:write"]}},"/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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"],"description":"Pagination: Returns the complete referee list for one candidate. No after cursor is accepted."},"post":{"summary":"Add referee","operationId":"createReferee","tags":["Referees"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"anyOf":[{"required":["email"]},{"required":["phone"]}],"properties":{"name":{"type":"string"},"email":{"type":"string","format":"email"},"phone":{"type":"string"},"job_title":{"type":"string"},"company":{"type":"string"},"relationship":{"type":"string"},"worked_from":{"type":"string","description":"YYYY-MM."},"worked_to":{"type":"string","description":"YYYY-MM."},"how_worked_together":{"type":"string"}}}}}},"responses":{"201":{"description":"Referee added","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Referee"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"job_title":{"type":"string","nullable":true},"company":{"type":"string","nullable":true},"relationship":{"type":"string","nullable":true},"worked_from":{"type":"string","nullable":true},"worked_to":{"type":"string","nullable":true},"how_worked_together":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"$ref":"#/components/schemas/Referee"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"]}},"/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. Client-side stages still require submission evidence, as documented on POST /applications/{id}/stage. Use this endpoint instead of hardcoding the list.\n\nPagination: Returns the complete static pipeline-stage catalogue. No after cursor is accepted.","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)."}}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":[]}],"x-required-scopes":[]}},"/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.\n\nPagination: Returns the complete transactional-template catalogue. No after cursor is accepted.","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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"}],"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}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:read"]}],"x-required-scopes":["interviews:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["interviews:write"]}],"x-required-scopes":["interviews:write"]}},"/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"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"}],"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:read"]}],"x-required-scopes":["emails:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:read"]}],"x-required-scopes":["emails:read"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["emails:write"]}],"x-required-scopes":["emails:write"]}},"/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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"],"description":"Pagination: Returns the complete workspace user directory. No after cursor is accepted."},"post":{"summary":"Invite a user","operationId":"createUser","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"],"description":"Pagination: Returns the complete workspace team configuration. No after cursor is accepted."},"post":{"summary":"Create team","operationId":"createTeam","tags":["Accounts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]},"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"],"description":"Pagination: Returns the complete workspace API-key configuration. No after cursor is accepted."},"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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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."}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/accounts/{id}/sso":{"get":{"summary":"List SSO connections","operationId":"listSsoConnections","tags":["Accounts"],"description":"Returns all SSO connections configured for this account.\n\nPagination: Returns the complete workspace SSO configuration. No after cursor is accepted.","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"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]},"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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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"}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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)."}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]}},"/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"}},{"$ref":"#/components/parameters/idempotencyKey"}],"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"}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/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}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]}},"/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}}}}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:read"]}],"x-required-scopes":["accounts:read"]}},"/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)."}},"security":[{"bearerAuth":[]},{"appOAuth2":["accounts:write"]}],"x-required-scopes":["accounts:write"]}},"/outreach":{"get":{"summary":"List outreach records","operationId":"listOutreach","tags":["Outreach"],"description":"The outreach you have recorded against candidates, newest first.","parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"},{"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:read"]}],"x-required-scopes":["candidates:read"]},"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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["candidates:write"]}],"x-required-scopes":["candidates:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/batch":{"post":{"summary":"Execute multiple actions in one request","operationId":"executeBatch","tags":["Batch"],"description":"Dispatch up to 100 canonical actions 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.\n\nThe callable action catalogue is generated from the same action registry the product dispatches and the same scope map this endpoint enforces.\n\n| Action | Required scope | Payload | What it does |\n| --- | --- | --- | --- |\n| `add_client_contact` | `clients:write` | `{ client_id? \\| client_name?, name, email?, phone?, title?, reports_to?, is_primary?, source? }` | Add a contact (hiring manager, HR, finance) to a client. Pass client_id, or client_name when you only know the company - a client with that name is matched, and created if the agency does not have one yet. A contact with the same email on the same client is handed back rather than duplicated. |\n| `add_client_dna_note` | `clients:write` | `{ client_id, text, source? }` | Add a note to a client's DNA - anything learned from a meeting, call, or feedback. The DNA re-derives automatically with the note applied. source is text (default), voice, or meeting. |\n| `add_client_recruiter` | `clients:write` | `{ client_id, member_id }` | Add a teammate as a recruiter on a client. Anyone who works a job at the client is added automatically - this is the manual way to add anyone else. |\n| `add_note` | `activities:write` | `{ record_type, record_id, note_text }` | Add a note to a candidate / job / application / interview / client record. |\n| `add_quota_adjustment` | `accounts:write` | `{ amount, reason, user_id?\\|user_email?, currency?, period_start? }` | Credit or debit a consultant's quota figure (or the agency's, with no consultant named) inside a period, with a reason - a deal counted in a different period, a split correction. Amount is signed: negative debits. Defaults to the current period; locked periods refuse. |\n| `add_referee` | `candidates:write` | `{ application_id, name, email, relationship?, phone? }` | Record a referee the candidate gave directly (name + email, optional relationship and phone) against their application. For asking the candidate to submit their own referees, use request_referee_details instead. |\n| `add_to_talent_pool` | `candidates:write` | `{ candidate_id, pool_id? \\| pool_name?, reason? }` | Add a candidate to a talent pool. Pass pool_id, or pool_name when the user names the pool (e.g. \"add Jane to the engineering pool\"). |\n| `answer_spec_question` | `submissions:write` | `{ spec_id, body }` | Answer a question a client asked about a spec (float). The reply is stored on the spec's Q&A thread and the client contact is emailed the answer. |\n| `answer_submission_question` | `submissions:write` | `{ item_id, body, resend_link? }` | Answer a question a client asked about a submitted candidate, or send them any message on that submission. The client contact is emailed it. Pass resend_link: true when the message is sending the client link again - the link is renewed first (another 7 days, and an expired one comes back to life), so the client never gets a dead link. A revoked submission is never resent. |\n| `approve_join_request` | `talent_pools:write` | `{ request_id }` | Approve a pending talent pool join request. Creates or matches the candidate record and adds them to the pool. |\n| `ask_analytics` | `analytics:read` | `{ question }` | Ask any data question over the agency's records - billings, placements, submissions, ratios, fill rate, rankings by client or consultant, trends, period comparisons. A deterministic engine computes every number and the answer states exactly what was computed. |\n| `ask_bd_target` | `clients:read` | `{ target_id \\| target_name, question }` | Ask any question about an employer on the BD territory map - what they are hiring, advertised salaries, why they score, what has changed. Answered only from the map's own evidence, and it never recommends outreach. |\n| `ask_candidate` | `candidates:read` | `{ candidate_id \\| candidate_name, question }` | Ask any question about a candidate in natural language - history with the agency, live applications, client responses, when you last spoke. Answered only from the agency's own records for that candidate, never from outside data. |\n| `ask_client` | `clients:read` | `{ client_id \\| client_name, question }` | Ask any question about a client in natural language - reply speed, placement history, who to call, whether they are a good client, and grounded public BD signals. Public facts always keep their verified source. |\n| `ask_job` | `jobs:read` | `{ job_id \\| job_title, question }` | Ask any question about a job in natural language - how the pipeline is tracking, who is stalled, what is waiting on the client. Answered only from the agency's own records for that job, never from outside data. |\n| `bulk_add_to_talent_pool` | `candidates:write` | `{ candidate_ids: [...], pool_id? \\| pool_name? }` | Add multiple candidates to a talent pool at once. |\n| `bulk_reject` | `applications:write` | `{ application_ids: [...] }` | Reject multiple applications at once. |\n| `bulk_send_email` | `emails:write` | `{ candidate_ids: [...], subject, body }` | Send the same email to multiple candidates. |\n| `cancel_interview` | `interviews:write` | `{ interview_id, reason? }` | Cancel a scheduled interview. Sends a cancellation notice. |\n| `cancel_meeting` | `interviews:write` | `{ event_hint, reason? }` | Cancel a previously booked meeting. Pass the user's phrasing verbatim as event_hint. Soft-deletes the row (cancelled_at) and removes the Google event. |\n| `cancel_scheduled_email` | `emails:write` | `{ scheduled_email_id }` | Cancel an email that has been scheduled but not yet sent. |\n| `chase_spec` | `submissions:write` | `{ spec_id, email_subject, email_body }` | Nudge the client contact about a spec they have not responded to. The email body is written by the consultant (or pre-drafted for them) - it is never sent without a person approving it. Stamps the reminder and clears the Needs You prompt. |\n| `chase_submission` | `submissions:write` | `{ submission_id, email_subject, email_body }` | Nudge the client contact about a submission they have not responded to. The email body is written by the consultant (or pre-drafted for them) - it is never sent without a person approving it. Stamps the reminder and clears the Needs You prompt. |\n| `close_job` | `jobs:write` | `{ job_id, reason? }` | Close a job (sets status=Filled). Existing applications are preserved. |\n| `complete_assessment` | `applications:write` | `{ candidate_id, job_id }` | Fill in the assessment stages the arrival cascade skipped (gated Client Fit, deferred summary) for a candidate on a job. No-op when the assessment is already complete; never re-scores what was scored. |\n| `confirm_candidate_draft` | `candidates:write` | `{ review_token, name?, email?, phone?, linkedin_url?, location?, current_title?, current_company? }` | Apply any final field corrections, then create the real candidate record. Refuses with draft_has_gaps if name or email is empty. Dispatches to the existing create_candidate handler. |\n| `confirm_email_draft` | `emails:write` | `{ review_token }` | Send the staged email now (or schedule it if schedule_at is set). Refuses with draft_has_gaps if subject or body is empty. Dispatches to the existing send_email / schedule_email handlers. |\n| `confirm_interview_action_draft` | `interviews:write` | `{ review_token, new_scheduled_at?, ignore_conflicts? }` | Reschedule or cancel the interview now. Refuses with draft_has_gaps if new_scheduled_at (reschedule) or reason (cancel) is empty. Dispatches to the existing reschedule_interview / cancel_interview handler. A reschedule clash refuses with code \"conflicts\" naming who is busy; pass new_scheduled_at to re-confirm at a suggested time, or ignore_conflicts: true only after the user explicitly says to move it anyway. |\n| `confirm_interview_draft` | `interviews:write` | `{ review_token, scheduled_at?, ignore_conflicts? }` | Schedule the interview now. Refuses with draft_has_gaps if scheduled_at, format, or interviewer_id is empty (or if format is in_person without a location). Dispatches to the existing schedule_interview handler. A free/busy clash refuses with code \"conflicts\" naming who is busy; pass scheduled_at to re-confirm at a suggested time, or ignore_conflicts: true only after the user explicitly says to book anyway. |\n| `confirm_interview_form_draft` | `interviews:write` | `{ instance_id }` | Submit the interview form. Refuses with draft_has_gaps if overall recommendation is missing or no section has been scored. Dispatches to the existing update_interview_outcome handler. |\n| `confirm_job_draft` | `jobs:write` | `{ review_token, mode: \"active\"\\|\"draft\", client_id?, team_id?, hiring_manager_id? }` | Create the real job and fire the full create_job pipeline (criteria, interview questions, ad, social posts). Refuses if any mandatory gap is unfilled. |\n| `confirm_phone_screen_draft` | `applications:write` | `{ review_token }` | Atomically log a timeline note and move the application stage. advance -> shortlisted (via move_stage); reject -> rejected (via reject_candidate); hold / no_show -> stage unchanged. Refuses with draft_has_gaps if outcome or notes is empty. |\n| `confirm_scorecard_draft` | `interviews:write` | `{ review_token }` | Submit the staged scorecard. Refuses with draft_has_gaps if overall_recommendation is missing or every score is null. Dispatches to the existing submit_scorecard + update_interview_outcome handlers. |\n| `convert_bd_lead` | `clients:write` | `{ lead_id, website? }` | Convert a business development lead into a real client record. Enrichment and Client DNA kick off automatically (website inferred from the ad contact when not given). No job is created - the consultant briefs the role through the normal flow. |\n| `convert_bd_target` | `clients:write` | `{ target_id, website? }` | Convert a territory-map target into a real client record. Enrichment and Client DNA kick off automatically (website inferred from ad contact evidence when not given). No job is created - the consultant briefs the role through the normal flow. |\n| `convert_spec` | `jobs:write` | `{ spec_id, title?, location?, employment_type?: full_time\\|part_time\\|contract, salary_min?, salary_max?, currency? }` | The client is interested in a specced candidate: create the job at that client and add the candidate at the submitted stage in one step. Uses the real job + application creation paths; the spec keeps the introduction record. |\n| `create_application` | `applications:write` | `{ candidate_id \\| candidate_ids: [...], job_id, stage?, source_category?, source_detail?, talent_pool_id?, search_query?, sourcing_run_id?, notes? }` | Attach an existing candidate (or many) to an existing job. Pass candidate_id for a single attach or candidate_ids: [...] for bulk. The \"new\" stage is the default. Credit the source: pass talent_pool_id when attaching from a talent pool, search_query when attaching a database result, or sourcing_run_id for an explicit LinkedIn sourcing run. |\n| `create_candidate` | `candidates:write` | `{ name, email?, phone?, location?, skills? }` | Create a new candidate profile. |\n| `create_client` | `clients:write` | `{ name?, website?, phone?, industry?, description?, locations?, address?, standard_fee_percent?, payment_terms_days?, guarantee_days?, notes? }` | Create a client (a company the agency recruits for). The company phone is separate from every contact phone. Pass a website and Lovelio fills in missing details automatically. Initial agreed fee terms (e.g. importing your book) may be set here; later changes go through set_client_fee_terms. |\n| `create_distribution_rule` | `jobs:write` | `{ name, target_board_slugs: string[], filter?: { country?, category?, job_type?, team_id? }, active? }` | Create a job-board distribution rule: matching jobs auto-publish to the target boards. Filter by country, category, job_type, or team. |\n| `create_job` | `jobs:write` | `{ title, client_id \\| client_name, client_contact_id?, location?, type?, salary_min?, salary_max?, currency?, default_interview_format?, is_evergreen? }` | Create a new job requisition. Every job belongs to a client: pass client_id (or client_name - one unambiguous match). For internal hiring use the agency self-client. |\n| `create_job_ad` | `jobs:write` | `{ job_id, title?, description? }` | Create a new public job ad for a role. |\n| `create_placement` | `placements:write` | `{ application_id? \\| (candidate_id + client_id), job_id?, salary?, currency?, fee_type?, fee_percent?, fee_amount?, start_date?, guarantee_days?, owner_id?, notes?, placement_type?, offer_date?, contract_end_date?, charge_rate?, pay_rate?, rate_period?, expected_gp?, credits? }` | Record a placement: a candidate placed with a client for a fee. Pass application_id to log it against a live application (moves it to Placed), or candidate_id + client_id - the candidate must already be live on one of the client's OPEN jobs (Active or On Hold), and the placement links to that application. A closed or filled job is refused: reopen it first. Fee percent and guarantee default off the client's standard terms. |\n| `create_reminder` | `interviews:write` | `{ title, starts_at, notes?, candidate_id?, job_id?, application_id? }` | Create a reminder for the user at a specific time. |\n| `create_submission` | `submissions:write` | `{ job_id, application_ids: [...] \\| items: [{ application_id, summary? }], client_contact_id?, email_subject?, greeting?, headline?, intro_body?, message?, title?, identity_visibility? }` | Send a submission of candidates on a job to the job's client contact for review. The client gets a no-login link to request interviews, pass, or ask questions. Candidates still in triage move to the submitted stage automatically. identity_visibility decides how much of each candidate the client sees: \"full\" (default), \"first_name\" (first name only, no surname or LinkedIn), or \"anonymous\" (no name, and employers and schools withheld too). email_subject is the subject line the client sees - pass one when the consultant has written it, otherwise it defaults to \"Candidates for the role of {job}\" (singular \"Candidate\" for a submission of one). headline is the big line at the top of the client review page - pass one to replace the default (\"Hi {first name}, N candidates for your review.\"). intro_body is everything said above the candidates in the consultant's own words - pass one to replace the default paragraph (\"I am delighted to submit {first names} for your role of {job title}. Please see specific details below...\" signed off by the consultant). |\n| `create_talent_pool` | `talent_pools:write` | `{ name, eligibility?, description?, cover_color? }` | Create a new talent pool. Name is required; eligibility is the one-line \"who belongs here\" description. |\n| `create_webhook` | `webhooks:write` | `{ url, events?: string[] }` | Create a webhook subscription. Generates a signing secret returned once. |\n| `decline_join_request` | `talent_pools:write` | `{ request_id, note? }` | Decline a pending talent pool join request. Silent - the applicant is not emailed. |\n| `delete_candidate` | `candidates:write` | `{ candidate_id, reason? }` | Soft-delete a candidate (not GDPR hard delete). |\n| `delete_client` | `clients:write` | `{ client_id, reason? }` | Delete a client record. |\n| `delete_client_contact` | `clients:write` | `{ contact_id }` | Remove a contact from a client. |\n| `delete_document` | `documents:write` | `{ document_id }` | Delete one document. Removes the file itself, not just the listing - there is no undo. |\n| `delete_job` | `jobs:write` | `{ job_id, reason? }` | Soft-delete a job. Applications are preserved but the job is hidden. |\n| `delete_talent_pool` | `talent_pools:write` | `{ pool_id? \\| pool_name? }` | Permanently delete a talent pool. The pool and its membership list go; every candidate stays in your bank. Pass pool_id, or pool_name when the user names the pool. |\n| `delete_webhook` | `webhooks:write` | `{ webhook_id }` | Delete a webhook subscription. Past deliveries are preserved. |\n| `describe_a_job` | `jobs:write` | `{ description, client_id? \\| client_name?, autopublish?, source? }` | Turn a free-text role description into a full job draft (streaming in the background). Returns a review_token that every follow-up action uses. |\n| `dismiss_bd_lead` | `clients:write` | `{ lead_id }` | Dismiss a business development lead. Forever: a dismissed employer is never suggested again. |\n| `dismiss_bd_target` | `clients:write` | `{ target_id }` | Dismiss a territory-map target. Forever: it leaves the map and the employer is never suggested as a lead again. |\n| `email_client_contact` | `emails:write` | `{ client_contact_id, subject, body }` | Send an email to a contact at a client now. Tracked on the client timeline; replies thread back into Lovelio. |\n| `extract_candidate_preferences` | `candidates:write` | `{ candidate_id } then { candidate_id, apply: true, patch }` | Read the candidate's recent call notes and timeline notes and propose a preferences patch (preview first; { apply: true, patch } writes the confirmed proposal). |\n| `get_candidate_draft` | `candidates:read` | `{ review_token }` | Read the current state of a staged candidate draft plus remaining mandatory and warn-level gaps. |\n| `get_email_draft` | `emails:read` | `{ review_token }` | Read the current state of a staged email draft plus remaining gaps and the draft-generation task status. |\n| `get_interview_action_draft` | `interviews:read` | `{ review_token }` | Read the current state of a staged reschedule / cancel draft plus remaining gaps. |\n| `get_interview_draft` | `interviews:read` | `{ review_token }` | Read the current state of a staged interview draft plus remaining gaps. |\n| `get_interview_form_draft` | `interviews:read` | `{ instance_id }` | Read the current state of an interview form: instance, sections, scores, recommendation, gaps. |\n| `get_job_creation_status` | `jobs:read` | `{ task_id }` | Poll a create_job task runner for per-step progress (description, criteria, questions, ad, social). |\n| `get_job_draft` | `jobs:read` | `{ review_token }` | Read the current state of a streaming draft plus remaining gaps and task status. |\n| `get_phone_screen_draft` | `applications:read` | `{ review_token }` | Read the current state of a staged phone-screen draft plus remaining gaps. |\n| `get_scorecard_draft` | `interviews:read` | `{ review_token }` | Read the current state of a staged scorecard draft plus remaining mandatory and warn-level gaps. |\n| `invite_team_member` | `accounts:write` | `{ email, role ('admin'\\|'team_leader'\\|'recruiter'), name?, team_id? \\| team_name? }` | Invite a new team member by email with a role. Sends a magic-link invitation email. |\n| `list_documents` | `documents:read` | `{ record_type, record_id }` | List the documents filed against any record - a candidate, client, contact, job, placement, talent pool or BD target. |\n| `log_bd_target_touch` | `clients:write` | `{ target_id, note? }` | Record that you reached out to a territory-map target (call, coffee, email you sent yourself). Resets the cadence clock. Lovelio never drafts or sends the outreach. |\n| `log_call` | `activities:write` | `{ candidate_id? \\| client_contact_id? (+ client_id?), outcome? (connected\\|voicemail\\|no_answer), notes?, called_at?, spec_id?, submission_id? }` | Record a phone call with a person: a candidate, or a contact at a client (never a bare company - pass client_contact_id and client_id resolves from it). Feeds the timeline and the \"never called\" search filters. On a client call, pass spec_id or submission_id to make the call count as the chase: the reminder stamps and the follow-up card resolves, same as the emailed nudge. |\n| `log_offer` | `applications:write` | `{ application_id, offered_salary?, offered_currency?, offer_date? }` | Record that the client has made an offer to the candidate and move the application to the Offer stage. Stores the offered salary and offer date on the application. In agency perm the client owns the offer document - this is a stage marker with the offer's details, not an offer letter. |\n| `mark_placement_status` | `placements:write` | `{ placement_id, status, reason? }` | Mark a placement as pending start, started, fell off (during guarantee), or completed. |\n| `match_candidates_to_job` | `candidates:read` | `{ job_id, limit? }` | Search the whole candidate database for the best matches to a job, ranked with a short why-them line per candidate. Read-only: attach a match with create_application (pass search_query so the source is credited as Database Search). |\n| `merge_candidates` | `candidates:write` | `{ primary_id, duplicate_id }` | Merge a duplicate candidate into a primary candidate. |\n| `move_stage` | `applications:write` | `{ application_id, new_stage, reason? }` | Move an application to a different pipeline stage. |\n| `plan_linkedin_sourcing` | `jobs:read` | `{ job_id, force? }` | Build four LinkedIn sourcing searches from a job's full structured requirements, prior placement signals and explicit search feedback. Returns direct, adjacent, feeder-company and hidden-gem lanes. It never runs or automates LinkedIn. |\n| `prepare_cancel_interview` | `interviews:write` | `{ interview_id, reason?, notify_candidate? }` | Stage a cancel draft for an existing interview. Reason is mandatory before confirm. Returns a review_token every follow-up action uses. |\n| `prepare_candidate_from_cv` | `candidates:write` | `{ file_base64, mime_type?, file_name?, initial?: { name?, email?, phone?, linkedin_url?, location? }, slack_file_id?, source? }` | Parse an uploaded CV and stage a candidate draft with pre-filled details. Typed details in initial take precedence over extracted identity fields, and conflicts are returned for review. Returns a review_token every follow-up action uses. |\n| `prepare_compose_email` | `emails:write` | `{ candidate_id, application_id?, intent? (advance\\|reject\\|schedule\\|offer\\|freeform), instructions?, schedule_at? }` | Stage an email draft for a candidate. Kicks off a background Haiku draft in the company brand voice. Returns a review_token every follow-up action uses. |\n| `prepare_interview_form` | `interviews:write` | `{ interview_id, regenerate? }` | Generate (or return the existing) AI interview form for an interview. One form per interview; idempotent unless regenerate is true. Refuses regenerate after any answer has been saved. |\n| `prepare_phone_screen` | `applications:write` | `{ application_id, initial_outcome?, initial_notes?, call_at?, duration_min?, source? }` | Stage a phone-screen log for an application. Outcome is one of advance, reject, hold, no_show; notes are required. Returns a review_token every follow-up action uses. |\n| `prepare_reschedule_interview` | `interviews:write` | `{ interview_id, new_scheduled_at?, reason?, notify_candidate? }` | Stage a reschedule draft for an existing interview. Captures the new datetime and optional reason. Returns a review_token every follow-up action uses. |\n| `prepare_schedule_interview` | `interviews:write` | `{ application_id, scheduled_at?, format?, interviewer_id?, duration_min?, location?, notes? }` | Stage an interview draft for an application. Predicts format + interviewer from the job defaults so the recruiter has fewer fields to fill. Returns a review_token every follow-up action uses. |\n| `prepare_screening_form` | `forms:write` | `{ application_id, mode?: ai \\| template, template_form_id?, regenerate?, brief? }` | Load, generate or regenerate the candidate-aware phone screen form for an application. The caller chooses AI or a template for each screen. |\n| `prepare_submit_scorecard` | `interviews:write` | `{ interview_id, source? }` | Stage a scorecard draft for an interview. Looks up the job criteria and pre-populates a scores array with one entry per criterion. |\n| `promote_client_contact` | `clients:write` | `{ contact_id }` | Turn a \"works here\" person - someone whose own record says they work at this client - into a contact the agency deals with. They move into the client's main contact list, and approach mail to them is blocked from then on the way it is for every other contact. |\n| `propose_interview_slots` | `interviews:read` | `{ interviewer_ids: [user_id, ...], duration_min?, from_date?, to_date?, every_time? }` | Return ~12 candidate day/time options that are free for all of the given interviewers, sourced from the Lovelio calendar (and Google free/busy when connected). Pass every_time: true for the full list of free times inside everyone's preferred hours instead of a spread - that is for a picker UI, never for an answer in chat. Read-only. |\n| `pursue_bd_target` | `clients:write` | `{ target_id, stop? }` | Mark a territory-map target as one you are actively pursuing (it pins to the top of the map). Pass stop: true to put it back among the targets. |\n| `reassess_application` | `applications:write` | `{ application_id }` | Queue a fresh candidate assessment for an application after its candidate, job requirements or Client DNA changed. |\n| `reassign_record` | `accounts:write` | `{ new_owner_id, record_type? + (record_id \\| record_ids) \\| from_owner_id, keep_team? }` | Move ownership of clients, jobs, candidates, applications, placements, specs or BD targets to another consultant. Records with a team follow the new owner's desk unless keep_team is true. Pass from_owner_id (no ids) to move EVERYTHING a departing consultant owns. |\n| `record_client_verdict` | `submissions:write` | `{ item_id, response: \"interview_requested\" \\| \"rejected\", note?, rejection_reason?, wants_more_candidates? }` | Record what the client said about a submitted candidate when they told you directly instead of using their review link: they want to interview, or they passed (with an optional reason, their words, and whether they want to see more candidates for the role). Same write path as the client portal, so the pipeline, timeline, client DNA and Needs You queue all update identically. |\n| `reject_candidate` | `applications:write` | `{ application_id, reason? }` | Reject a candidate (sets stage=rejected; the rejection email only fires for candidates who applied themselves via the careers page or a job board - agency-added candidates are never emailed). |\n| `remove_client_recruiter` | `clients:write` | `{ client_id, member_id }` | Remove a teammate from a client's recruiter list. |\n| `remove_from_talent_pool` | `talent_pools:write` | `{ candidate_id, pool_id? \\| pool_name?, reason? }` | Remove a candidate from a talent pool. Pass pool_id, or pool_name when the user names the pool. |\n| `reopen_job` | `jobs:write` | `{ job_id, reason? }` | Reopen a closed or filled job (sets status=Active). |\n| `request_interview` | `interviews:write` | `{ application_id, interviewer_ids: [user_id, ...], duration_min, selected_slots: [iso, ...] (1-12, offer-times) \\| scheduled_at (iso, set-the-time), client_contact_ids?, email_subject, email_body, format?, location?, video_link?, flavour?, submission_id?, ignore_conflicts? }` | Book an interview, two ways. OFFER TIMES (internal interviews only): a curated set of times goes to the candidate with a \"pick a time\" button; valid on an application marked Yes, Screen, Submitted, Client interview or References. SET THE TIME (either flavour, whenever the time is already agreed): pass scheduled_at with an exact ISO datetime and the interview books immediately - everyone on it gets the invite and .ics at once. A client set-the-time booking requires at least one client_contact_id (the client hosts it); an internal one requires at least one interviewer (the caller by default) and no client contacts. flavour is internal (default by stage) or client - a client booking moves the application to client_interview. A client interview is the CLIENT meeting the candidate, so it takes no interviewer_ids (nobody from the agency attends), no Video format and no video_link, and it cannot offer times - all four are refused. An internal interview takes no client_contact_ids. location holds an in-person address (the form defaults it to the client address); on an internal Video interview a Lovelio call room is attached unless video_link holds a pasted link. Set-the-time bookings check everyone's free/busy first: a clash refuses with code \"conflicts\" naming who is busy, plus alternative times. Only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |\n| `request_marketplace_candidate` | `marketplace:write` | `{ listing_id, note?, accept_terms? }` | Ask the agency behind a marketplace candidate listing for an intro, with a note on the role you have in mind. If they accept, the candidate's details and both consultants' contacts reveal on the deal and the declared split is locked in. |\n| `request_referee_details` | `forms:write` | `{ application_id, min_required (1-5), email_subject?, email_body }` | Email a candidate a secure link to submit their referee contact details (up to 5). The recruiter sets the minimum. Submitted details are saved on the candidate master record and the recruiter is notified when they arrive. |\n| `reschedule_interview` | `interviews:write` | `{ interview_id, scheduled_at, reason?, ignore_conflicts? }` | Change the scheduled_at time of an interview. Re-checks every attendee's free/busy (the interview's own calendar event never counts against its new time): a clash refuses with code \"conflicts\" naming who is busy; pass a suggested scheduled_at to retry, or ignore_conflicts: true only after the user explicitly says to move it anyway. |\n| `reschedule_meeting` | `interviews:write` | `{ event_hint, new_starts_at, new_ends_at?, ignore_conflicts? }` | Move a previously booked meeting to a new time. Pass the user's phrasing (e.g. \"Derren's meeting tomorrow\") verbatim as event_hint - the matcher does the fuzzy work. If multiple meetings match, the user is asked which. A free/busy clash refuses with code \"conflicts\"; only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |\n| `resolve_identity_review` | `accounts:write` | `{ review_id, decision: accept \\| reject }` | Decide a company or person match Lovelio was not sure about (Settings > Workspace > Review queue). Accept merges two company records into one organisation, or links a candidate and a client contact as one person; reject keeps them separate and the question is never asked again. |\n| `resolve_occupation_review` | `accounts:write` | `{ title_norm, decision: accept \\| dismiss, label?, isco_code? }` | Decide the occupation behind a job title Lovelio could not place on its own - a rank with no function like \"Director\", or a title two models disagreed about. Accept records the occupation for everyone holding that title; dismiss records that the title does not map to one occupation, and it is never asked again. |\n| `resolve_review_item` | `accounts:write` | `{ review_item_id \\| review_item_ids, resolved?, snoozed_until? }` | Mark one or more review queue items resolved or snooze them until a future date. |\n| `respond_to_marketplace_intro` | `marketplace:write` | `{ intro_id, intro_action: check\\|accept\\|decline\\|withdraw, decline_reason? }` | Act on an intro: check runs the prior-knowledge read (is this candidate already in the receiving database?) without revealing anything; accept forms the deal, locks the split, and reveals identity; decline passes with an optional reason; withdraw pulls back your own pending intro. |\n| `revise_email_draft` | `emails:write` | `{ review_token, instruction }` | Apply a natural-language instruction (\"make it warmer\", \"cut to two paragraphs\") to a staged email draft. Returns the full updated subject + body. |\n| `revise_job_draft` | `jobs:write` | `{ review_token, instruction }` | Apply a natural-language instruction (\"make it punchier\", \"move to Sydney\") to a draft. Returns the full updated draft. |\n| `rewrite_job_ad` | `jobs:write` | `{ job_ad_id, instruction }` | Apply a natural-language instruction to rewrite an existing job ad using the company brand voice. |\n| `run_report` | `analytics:read` | `{ report: weekly_bd_pulse \\| quiet_clients \\| cold_contacts \\| fill_rate \\| business_numbers \\| revenue_analysis \\| money_moments \\| referee_details, confirm_cost? }` | Run a stored report. The Weekly pulse appears under Opportunities; quiet clients, contacts going cold, fill rate, agency numbers, revenue analysis, money moments, and referee details appear under Analytics. Money moments is one persons own copy and is never shared: an admin counts the whole agency, a team leader counts their team, a consultant counts only themself. The pulse and money moments also run automatically every Monday. Reports that spend money need confirm_cost: true. |\n| `save_current_view` | `accounts:write` | `{ name, page ('jobs'\\|'candidates'), query?, query_params? }` | Save the current search query as a named view. |\n| `schedule_call` | `interviews:write` | `{ title, starts_at, ends_at?, candidate_id?, notes? }` | Schedule a phone call (with a candidate or internal). |\n| `schedule_email` | `emails:write` | `{ candidate_id, subject, body, scheduled_at }` | Schedule an email to send at a future time. |\n| `schedule_interview` | `interviews:write` | `{ application_id, scheduled_at?, format?, flavour?, submission_id?, ignore_conflicts? }` | Schedule an interview for a candidate on an application. flavour is internal (agency screen, default) or client (candidate meets the client - moves the application to client_interview and can link to the submission it came from). Booking an exact time checks everyone's free/busy first: a clash refuses with code \"conflicts\" naming who is busy, plus alternative times. Only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |\n| `schedule_meeting` | `interviews:write` | `{ title, starts_at, ends_at?, attendees: [{ kind, id? \\| email + name }], notes?, ignore_conflicts? }` | Schedule a meeting (generic calendar event). Attendees must be resolved records - pass { kind: \"hiring_manager\"\\|\"user\"\\|\"candidate\", id } from search, or { kind: \"external\", email, name } for anyone outside the workspace. A free/busy clash refuses with code \"conflicts\"; only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |\n| `send_ai_voice_screen` | `applications:write` | `{ application_id }` | Invite a candidate to a short AI voice screening conversation. Emails them a no-login link; the transcript and per-question scores are saved on the application when they finish. Re-sends the existing link if one is still open. |\n| `send_email` | `emails:write` | `{ candidate_id, subject, body, job_id? }` | Send an email to a candidate now. |\n| `send_form` | `forms:write` | `{ form_id? \\| activity?, client_contact_id? \\| candidate_id, application_id?, interview_id?, job_id? }` | Email any form to a client contact or a candidate via a secure link - interview feedback, onboarding, background checks, or any form built in Settings > Forms. Lovelio follows up with non-responders and the answers are saved on the record. Pass form_id, or activity for a sendable slot (client_feedback_email, candidate_feedback_email). |\n| `send_interview_followup` | `interviews:write` | `{ interview_id, recipient: \"candidate\"\\|\"client\", email_subject, email_body }` | Send a post-interview follow-up: recipient \"candidate\" for the debrief / news / no-show note, recipient \"client\" for the next-steps or feedback note to the client contact (client interviews only). Always consultant-approved copy - never sent automatically. |\n| `send_marketplace_listing` | `marketplace:write` | `{ listing_id, recipient_email, recipient_name?, recipient_agency?, message? }` | Send one of your own live marketplace listings to a partner agency that is NOT on Lovelio, as a tokenised link. The partner sees only the anonymous listing plus the declared split, and answers interested or not with one click, without an account. You get their response as a notification with their contact details; the deal itself runs off-platform. |\n| `send_placement_email` | `placements:write` | `{ placement_id, recipient: \"candidate\"\\|\"client\", email_subject, email_body }` | Send a placement lifecycle check-in: recipient \"candidate\" for the start-day good-luck note, recipient \"client\" for the guarantee-end check-in. Always consultant-approved copy - never sent automatically. |\n| `send_referee_questionnaires` | `forms:write` | `{ application_id, referee_ids?, remind? }` | Email the reference form to every referee the candidate submitted for an application, in one go. Referees who already have one are skipped; optionally restrict with referee_ids. Pass remind: true to instead send a follow-up reminder to every referee whose form is still outstanding, on their existing link. |\n| `send_reference_request` | `forms:write` | `{ application_id, referee_id, form_id? }` | Create a reference check form instance for a referee and send it to them. |\n| `send_references_to_client` | `forms:write` | `{ application_id, reference_ids?, client_contact_id?, email_subject?, greeting?, email_body? }` | Send a candidate's completed references to the job's client contact as a secure no-login link, one link per referee, showing every question and the referee's own answer. Only references that are back can be sent, and a referee who said we may not quote them to the client is always left out. Optionally restrict with reference_ids. |\n| `send_screening_form` | `forms:write` | `{ application_id, form_id? }` | Create a screening form instance for an application and send it to the candidate. |\n| `send_spec` | `submissions:write` | `{ candidate_id, client_id, client_contact_id, headline, pitch, chips?: [...], why_client?, email_subject, email_body, attach_terms?, candidate_aware? } or batch { candidates: [{ candidate_id, headline, pitch, chips?, why_client?, candidate_aware? }] (max 5), client_id, client_contact_id, email_subject, email_body, attach_terms? }` | Send a spec (float in AU/NZ): candidates pitched to a client with no open job. The client contact gets ONE short personal email with a link to each fully anonymous profile page, where they respond one-click with no login, and the agency terms of business attached. Up to 5 candidates per send; each keeps its own spec record so the client responds per person. Client, contact, and candidates must exist. Never sent without consultant-approved copy. |\n| `set_ai_screening` | `jobs:write` | `{ job_id, enabled?, focus?: string[] }` | Turn automatic AI voice screening on or off for a job, and set what the screen should focus on (a short list of focus areas). When on, every inbound applicant whose AI verdict is yes or maybe is invited automatically; candidates a consultant adds by hand are never auto-invited. |\n| `set_candidate_cv` | `documents:write` | `{ candidate_id, document_id }` | Make one of a candidate's documents their current CV - the file the CV viewer, the resume parser and the API all read. |\n| `set_client_fee_terms` | `clients:write` | `{ client_id, terms?: { currency, fee_basis, tiers, minimum_fee?, guarantee_days?, guarantee_remedy, guarantee_notes?, payment_terms_days? }, revert? }` | Set the fee terms for a client (fee percent or bands, minimum fee, guarantee, payment terms), or return them to the agency standard with revert: true. Admin-only: client terms are the one source of truth for fees and every job under the client inherits them. |\n| `set_placement_credits` | `placements:write` | `{ placement_id, credits: [{ user_id, percent, role? }] }` | Set who gets credit for a placement: one line per consultant with a percent (must total 100) and an optional role (candidate_consultant, job_owner, business_development, other). Credit share is the consultant's billing figure everywhere. |\n| `set_quota_target` | `accounts:write` | `{ scope: agency\\|team\\|user, amount\\|null, period_start?, team_id?\\|team_name?, user_id?\\|user_email? }` | Set (or clear, with amount null) a billings target for a quota period: the whole agency (scope agency), a team (scope team, by team_id or team_name), or a consultant (scope user, by user_id or user_email). Defaults to the current period; pass period_start (YYYY-MM-DD, any date inside the period) for another one. Targets are optional at every level. |\n| `share_candidate_to_marketplace` | `marketplace:write` | `{ candidate_id, headline, pitch, chips?: [...], location?, owner_share_percent: 50\\|60\\|70\\|80, candidate_aware: true, accept_terms? }` | Put an anonymised candidate on the cross-agency split marketplace so other Lovelio agencies can request them for their clients. The candidate is never named until an intro is accepted, and must know they are being represented (candidate_aware is required). The listing declares the fee split and runs for 30 days. |\n| `share_job_to_marketplace` | `marketplace:write` | `{ job_id, headline, pitch, chips?: [...], location?, salary_min?, salary_max?, currency?, fee_percent?, owner_share_percent: 50\\|60\\|70\\|80, accept_terms? }` | Put an anonymised role on the cross-agency split marketplace so other Lovelio agencies can submit candidates. The client is never named; the posting agency is. The listing declares the fee split (owner keeps 50/60/70/80 percent) and runs for 30 days. Copy is consultant-approved, scrubbed server-side, and moderated before going live. |\n| `skip_job_draft_gap` | `jobs:write` | `{ review_token, field }` | Record that the user opted to leave a gap blank. (The confirm step still refuses mandatory gaps.) |\n| `start_video_call` | `interviews:write` | `{ interview_id, skip_email? }` | Create a Lovelio video call room on an interview and email the candidate a no-login join link. The call is recorded (audio) and transcribed live; recording and transcript are saved on the interview. Re-uses the open link if one exists. |\n| `submit_scorecard` | `interviews:write` | `{ interview_id, scores, recommendation, notes? }` | Submit an interview scorecard / feedback for an interview. |\n| `submit_to_marketplace_job` | `marketplace:write` | `{ listing_id, candidate_id, headline, pitch, chips?: [...], note?, attested_relationship: true, accept_terms? }` | Put one of your candidates forward for another agency's marketplace job listing. The owner sees an anonymous profile and your agency's name; the candidate's identity reveals only if the owner accepts the intro, which locks the declared split into a deal. Requires the attestation that you have interviewed the candidate and they agreed to be represented. |\n| `update_application` | `applications:write` | `{ application_id, patch: { source?, owner_id? } }` | Update source / owner on an application (not stage - use move_stage). |\n| `update_bd_opportunity` | `clients:write` | `{ opportunity_id, status: \"new\" \\| \"seen\" \\| \"handled\" \\| \"dismissed\" }` | Mark a grounded public BD opportunity as seen, handled, not useful, or new again. This changes workflow state only. It never edits the source fact or contacts the company. |\n| `update_bd_patch` | `clients:write` | `{ draft: true } or { statement }, then { apply: true, statement, profile }` | Save the agency's own description of what they recruit (roles, regions, seniority, anything they never touch). Call with { statement } first - it returns data.preview with the AI's reading played back, no write. Then call again with { apply: true, statement, profile: preview.profile } to save. For an agency that has not described anything yet, call { draft: true } instead: it reads the enrichment already in the account and returns data.draft with role and sector chips to remove plus three territories (city, region, country) to choose from, no write. Removing a SECTOR chip states an exclusion, which removes those employers from the map - that is how exclusions get stated at all, since no agency publishes what it will not do. Removing a ROLE chip only leaves it undeclared, so Lovelio stops sweeping the boards for it without blocking any employer. The patch shapes the BD map: declared desks count as fit, exclusions remove targets. |\n| `update_brand_voice` | `accounts:write` | `{ prompt }` | Update the company's brand voice prompt used by every AI email/job ad generation. |\n| `update_candidate` | `candidates:write` | `{ candidate_id, patch: { name?, email?, phone?, location?, linkedin_url?, skills?, current_employer?, current_job_title?, rating?, open_to_work?, do_not_approach? } }` | Update fields on a candidate (name, email, phone, current_employer, current_job_title, rating 1-5, open_to_work, do_not_approach, etc.). |\n| `update_candidate_draft` | `candidates:write` | `{ review_token, name?, email?, phone?, linkedin_url?, location?, current_title?, current_company? }` | Patch a staged candidate draft (name / email / phone / linkedin_url / location / current_title / current_company). Re-computes gaps. |\n| `update_candidate_preferences` | `candidates:write` | `{ candidate_id, patch: { preferred_roles?, preferred_seniority?, work_types?, open_to_relocation?, available_from?, preferred_locations?, salary_expectation?, notice_period?, work_rights? } }` | Update what a candidate WANTS: preferred roles, preferred locations (with relocation flag), work types (permanent/contract/temp/part_time), seniority preference, salary expectation, notice period / available-from date, work rights. |\n| `update_candidate_tags` | `candidates:write` | `{ candidate_id, tags?: string[] } or { candidate_id, add?: string[], remove?: string[] }` | Put flat labels on a candidate. Send tags to replace the whole list, or add / remove to change it without knowing the rest. |\n| `update_client` | `clients:write` | `{ client_id, patch: { name?, website?, phone?, industry?, description?, locations?, address?, notes? }, only_if_empty? }` | Update fields on a client (name, website, company phone, industry, locations, address, notes). Fee terms are NOT patchable here - set them with set_client_fee_terms. Pass only_if_empty with a single-field patch to fill a blank field without ever overwriting what is already there. |\n| `update_client_contact` | `clients:write` | `{ contact_id, patch: { name?, email?, phone?, title?, reports_to?, is_primary?, notes? } }` | Update a contact on a client. |\n| `update_distribution_rule` | `jobs:write` | `{ rule_id, patch: { name?, target_board_slugs?, filter?, active? } }` | Update a distribution rule (name, target boards, filter, or active state). |\n| `update_email_draft` | `emails:write` | `{ review_token, subject?, body?, schedule_at?, template_chip? }` | Patch a staged email draft (subject / body / schedule_at / template_chip). Re-computes gaps. |\n| `update_interview_action_draft` | `interviews:write` | `{ review_token, new_scheduled_at?, reason?, notify_candidate? }` | Patch a staged reschedule / cancel draft (new_scheduled_at / reason / notify_candidate). Re-computes gaps. |\n| `update_interview_draft` | `interviews:write` | `{ review_token, scheduled_at?, format?, interviewer_id?, duration_min?, location?, notes? }` | Patch a staged interview draft (scheduled_at / format / interviewer_id / duration_min / location / notes). Re-computes gaps. |\n| `update_interview_form_draft` | `interviews:write` | `{ instance_id, section_id?, section_score?, section_notes?, recommendation?, feedback? }` | Patch a section score / notes, or set the overall recommendation / final feedback on an interview form. |\n| `update_interview_outcome` | `interviews:write` | `{ interview_id, outcome, feedback? }` | Record the outcome (Passed / Failed / No Show) of an interview. Never moves the application - how the interview went and what happens next are separate decisions, and a human makes the second one. |\n| `update_job` | `jobs:write` | `{ job_id, patch: { title?, location?, status?, default_interview_format?, ... } }` | Update fields on a job (title, location, salary, default interview format, etc.). |\n| `update_job_ad` | `jobs:write` | `{ job_ad_id, title?, description?, published? }` | Edit the title / description / published flag of a job ad. |\n| `update_job_draft` | `jobs:write` | `{ review_token, updates: { title?, client_id?, team_id?, hiring_manager_id?, location?, compensation?, employment_type? } }` | Fill one or more gaps on a streaming draft. Each answer re-writes the summary via Haiku so the draft feels coherent. |\n| `update_marketplace_deal` | `marketplace:write` | `{ deal_id, deal_action: add_to_pipeline\\|mark_paid\\|mark_received\\|close, job_id?, reason? }` | Manage a formed marketplace deal. add_to_pipeline 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 way to link candidate-listing deals, where you pick the job). mark_paid/mark_received is the handshake on a placed deal: the placing agency marks the partner share paid; the receiving agency confirms it arrived. close ends an unplaced deal with a required reason the other agency reads (a placed deal never closes this way). Facts both sides see on the deal record - Lovelio never moves the money. |\n| `update_marketplace_listing` | `marketplace:write` | `{ listing_id, listing_action: pause\\|resume\\|withdraw\\|filled\\|renew }` | Pause, resume, withdraw, mark filled, or renew one of your marketplace listings. Renewing re-attests the listing is still real and extends it 30 days - expired listings never linger. |\n| `update_phone_screen_draft` | `applications:write` | `{ review_token, outcome?, notes?, call_at?, duration_min? }` | Patch a staged phone-screen draft (outcome / notes / call_at / duration_min). Re-computes gaps. |\n| `update_placement` | `placements:write` | `{ placement_id, patch: { salary?, currency?, fee_type?, fee_percent?, fee_amount?, start_date?, guarantee_days?, owner_id?, notes?, placement_type?, offer_date?, contract_end_date?, charge_rate?, pay_rate?, rate_period?, expected_gp? } }` | Update fields on a placement (salary, fee, start date, guarantee, owner, notes, contract rates and term, expected GP). |\n| `update_scorecard_draft` | `interviews:write` | `{ review_token, scores?, criterion_id?, score?, note?, overall_recommendation?, feedback? }` | Patch a staged scorecard draft (per-criterion score / note, overall_recommendation, feedback). Accepts either a full scores array replacement or a single { criterion_id, score?, note? } patch. |\n| `update_social_draft` | `jobs:write` | `{ job_id, linkedin?, twitter? }` | Edit a job's LinkedIn and X post text. |\n| `update_workflow_rules` | `accounts:write` | `{ instruction } then { apply: true, changes: [{ field, to }] }` | Change workflow rule settings in plain English. Call with { instruction } first - it returns data.preview of the exact changes without saving. Then call again with { apply: true, changes: data.preview } to save. Never apply without showing the user the preview. |\n| `upgrade_plan` | `accounts:write` | `{}` | Show the user the upgrade link for billing. |\n| `withdraw_application` | `applications:write` | `{ application_id, reason? }` | Record that a candidate has withdrawn from a role. |","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","enum":["add_client_contact","add_client_dna_note","add_client_recruiter","add_note","add_quota_adjustment","add_referee","add_to_talent_pool","answer_spec_question","answer_submission_question","approve_join_request","ask_analytics","ask_bd_target","ask_candidate","ask_client","ask_job","bulk_add_to_talent_pool","bulk_reject","bulk_send_email","cancel_interview","cancel_meeting","cancel_scheduled_email","chase_spec","chase_submission","close_job","complete_assessment","confirm_candidate_draft","confirm_email_draft","confirm_interview_action_draft","confirm_interview_draft","confirm_interview_form_draft","confirm_job_draft","confirm_phone_screen_draft","confirm_scorecard_draft","convert_bd_lead","convert_bd_target","convert_spec","create_application","create_candidate","create_client","create_distribution_rule","create_job","create_job_ad","create_placement","create_reminder","create_submission","create_talent_pool","create_webhook","decline_join_request","delete_candidate","delete_client","delete_client_contact","delete_document","delete_job","delete_talent_pool","delete_webhook","describe_a_job","dismiss_bd_lead","dismiss_bd_target","email_client_contact","extract_candidate_preferences","get_candidate_draft","get_email_draft","get_interview_action_draft","get_interview_draft","get_interview_form_draft","get_job_creation_status","get_job_draft","get_phone_screen_draft","get_scorecard_draft","invite_team_member","list_documents","log_bd_target_touch","log_call","log_offer","mark_placement_status","match_candidates_to_job","merge_candidates","move_stage","plan_linkedin_sourcing","prepare_cancel_interview","prepare_candidate_from_cv","prepare_compose_email","prepare_interview_form","prepare_phone_screen","prepare_reschedule_interview","prepare_schedule_interview","prepare_screening_form","prepare_submit_scorecard","promote_client_contact","propose_interview_slots","pursue_bd_target","reassess_application","reassign_record","record_client_verdict","reject_candidate","remove_client_recruiter","remove_from_talent_pool","reopen_job","request_interview","request_marketplace_candidate","request_referee_details","reschedule_interview","reschedule_meeting","resolve_identity_review","resolve_occupation_review","resolve_review_item","respond_to_marketplace_intro","revise_email_draft","revise_job_draft","rewrite_job_ad","run_report","save_current_view","schedule_call","schedule_email","schedule_interview","schedule_meeting","send_ai_voice_screen","send_email","send_form","send_interview_followup","send_marketplace_listing","send_placement_email","send_referee_questionnaires","send_reference_request","send_references_to_client","send_screening_form","send_spec","set_ai_screening","set_candidate_cv","set_client_fee_terms","set_placement_credits","set_quota_target","share_candidate_to_marketplace","share_job_to_marketplace","skip_job_draft_gap","start_video_call","submit_scorecard","submit_to_marketplace_job","update_application","update_bd_opportunity","update_bd_patch","update_brand_voice","update_candidate","update_candidate_draft","update_candidate_preferences","update_candidate_tags","update_client","update_client_contact","update_distribution_rule","update_email_draft","update_interview_action_draft","update_interview_draft","update_interview_form_draft","update_interview_outcome","update_job","update_job_ad","update_job_draft","update_marketplace_deal","update_marketplace_listing","update_phone_screen_draft","update_placement","update_scorecard_draft","update_social_draft","update_workflow_rules","upgrade_plan","withdraw_application"],"description":"Canonical action name. The generated catalogue above gives the scope, payload hint, and behaviour for every accepted value."},"payload":{"type":"object","description":"Action-specific payload. Use the payload hint in the generated catalogue above. Send the same typed external ids (job_, cnd_, app_, cli_, sub_, pla_) the rest of the API uses; the batch boundary decodes them 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. Every record id is returned as a typed external id, never a database UUID."},"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."}},"security":[{"bearerAuth":[]},{"appOAuth2":[]}],"x-required-scopes":[]}},"/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).\n\nPagination: Returns the complete static job-board catalogue. No after cursor is accepted.","responses":{"200":{"description":"Array of boards","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/DistributionBoard"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]}},"/distribution/rules":{"get":{"summary":"List distribution rules","operationId":"listDistributionRules","tags":["Distribution"],"parameters":[{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/after"}],"responses":{"200":{"description":"Array of rules","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/DistributionRule"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]},"post":{"summary":"Create a distribution rule","operationId":"createDistributionRule","tags":["Distribution"],"description":"Route matching jobs to boards. Free/mixed boards auto-publish; paid boards and boards with pricing null (not priced by Lovelio) 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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}},"/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"}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]},"delete":{"summary":"Delete a distribution rule","operationId":"deleteDistributionRule","tags":["Distribution"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"]}},"/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"},{"$ref":"#/components/parameters/after"}],"responses":{"200":{"description":"Array of postings","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"array","items":{"$ref":"#/components/schemas/JobBoardPosting"}}}}}}}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:read"]}],"x-required-scopes":["jobs:read"]}},"/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. Every board a matching rule selects is posted to automatically, whatever it costs - `auto` is the whole answer.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"job_id":{"type":"string"},"auto":{"type":"array","items":{"type":"string"}}}}}}}}},"202":{"description":"Async task accepted"}},"security":[{"bearerAuth":[]},{"appOAuth2":["jobs:write"]}],"x-required-scopes":["jobs:write"],"parameters":[{"$ref":"#/components/parameters/idempotencyKey"}]}}},"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 candidate submissions 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"}]}