{"openapi":"3.1.0","info":{"title":"Evidal Hiring Manager API","version":"1.0.0","summary":"Public REST surface used by the hiring-manager MCP server and by any tool or AI agent calling Evidal directly.","description":"The Evidal hiring-manager API lets a hiring manager — or an AI agent acting\non their behalf — manage roles, review candidates, configure Slack\nnotifications, and kick off Stripe billing flows. Every endpoint below is\nauthenticated with a company-scoped Bearer API key of the form\n`evidal_sk_...`.\n\nGenerate or rotate your API key from\n`https://app.evidal.ai/admin/{company}/integrations`.\n\nRate limit: **100 requests/hour per API key**. Exceeding the limit\nreturns `429 Too Many Requests`.\n\nSome admin-only flows (adding a payment method, opening the Stripe billing\nportal) require an authenticated browser session and are NOT exposed on\nthis API; use the admin dashboard for those.\n"},"servers":[{"url":"https://app.evidal.ai","description":"Production"}],"security":[{"BearerApiKey":[]}],"tags":[{"name":"Company","description":"Read/update the company record."},{"name":"Roles","description":"Role management (CRUD, lifecycle)."},{"name":"Candidates","description":"Candidate review + hiring decisions. PII is gated."},{"name":"Slack","description":"Notification routing."},{"name":"API key","description":"API key rotation."},{"name":"DNS","description":"Custom-domain verification status."},{"name":"Billing","description":"Stripe Checkout + per-role billing state."},{"name":"Members","description":"Hiring-team member management (invite, list, promote/demote, remove, revoke invite)."},{"name":"Onboarding","description":"Unauthenticated company self-serve signup. A caller POSTs to `/start` with\nthe owner's email + desired company name, receives a `session_id`, and\nwaits for the owner to click the magic-link in the verification email.\nThe session can then be completed (creating the company + first API key)\nor cancelled. All onboarding endpoints are public; no Bearer key is\nrequired or accepted.\n"},{"name":"Candidate self-service","description":"Endpoints a candidate (or their AI agent) calls about their own data.\nAuthenticated with the candidate session — the `evi_cand_session`\ncookie set at magic-link sign-in, or `Authorization: Bearer\n<candidate-session JWT>` for API/mobile callers. Company API keys are\nNOT accepted on these endpoints.\n"}],"paths":{"/api/v1/company/{slug}":{"patch":{"tags":["Company"],"operationId":"updateCompany","summary":"Update company-level fields.","description":"Patch the company. Via API key auth, `name`, `notification_email`,\n`description`, `logo_url`, `logo_icon_url`, and `logo_wordmark_url` are\naccepted. `logo_icon_url` is a square mark that does NOT contain the\nname (shown with the name beside it); `logo_wordmark_url` is the full\nlogo that DOES contain the name (shown alone, name not repeated); all\nthree are https images, SVG rejected, and `null` clears one. `domain`\nmutations are rejected — use the admin dashboard for DNS-verified\ndomain changes.\n","parameters":[{"$ref":"#/components/parameters/Slug"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":200},"notification_email":{"oneOf":[{"type":"string","format":"email"},{"type":"array","items":{"type":"string","format":"email"}}]},"description":{"type":"string","maxLength":5000},"logo_url":{"type":"string","format":"uri","nullable":true},"logo_icon_url":{"type":"string","format":"uri","nullable":true,"description":"EVI-475: square mark that does NOT contain the name (shown with the name beside it). https image, no SVG. null clears it."},"logo_wordmark_url":{"type":"string","format":"uri","nullable":true,"description":"EVI-475: full logo that CONTAINS the name (shown alone, name not repeated). https image, no SVG. null clears it."}},"additionalProperties":false}}}},"responses":{"200":{"description":"Updated company record.","content":{"application/json":{"schema":{"type":"object","properties":{"company":{"$ref":"#/components/schemas/Company"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/v1/company/{slug}/roles":{"get":{"tags":["Roles"],"operationId":"listRoles","summary":"List roles for the company.","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"include_inactive","in":"query","required":false,"schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Roles list (active only by default).","content":{"application/json":{"schema":{"type":"object","properties":{"roles":{"type":"array","items":{"$ref":"#/components/schemas/Role"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/roles/create":{"post":{"tags":["Roles"],"operationId":"createRole","summary":"Create a new role.","description":"New roles always start in `trial` status with 3 trial evaluations.\nAfter creation, call `PATCH /roles/{role_id}` to set `must_haves`,\n`nice_to_haves`, or `custom_notes`.\n","parameters":[{"$ref":"#/components/parameters/Slug"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title","level","ai_native_required"],"properties":{"title":{"type":"string","maxLength":200,"description":"Role title. Capped at 200 chars; a title that slugifies to empty (punctuation/unicode-only) is rejected with 422."},"level":{"type":"string"},"ai_native_required":{"type":"boolean"},"archetype_slug":{"type":"string"},"description":{"type":"string"},"location":{"type":"string","maxLength":200},"compensation":{"type":"string","maxLength":200},"work_arrangement":{"type":"string","enum":["remote","hybrid","onsite"],"description":"EVI-448. Optional — a role can be created without it and published without it; nothing is inferred from `location`. `remote` needs at least one country to go with it (`remote_countries`, or failing that `location_country`), otherwise no telecommute marker is published: a job board cannot place a remote posting that never says where the work may be done."},"location_country":{"type":"string","pattern":"^[A-Z]{2}$"},"location_region":{"type":"string","maxLength":120},"location_locality":{"type":"string","maxLength":120},"remote_countries":{"type":"array","maxItems":20,"items":{"type":"string","pattern":"^[A-Z]{2}$"}},"page_template":{"type":"string","enum":["plain","field_map","ledger","deck","dossier"],"description":"Layout of the public candidate role page (\"Posting page\"). Presentation only — never affects the evaluation or the score. Omit to use the default (`field_map`)."},"allowed_modes":{"type":"array","minItems":1,"items":{"type":"string","enum":["api","poll","web","webhook"]},"description":"EVI-426/448. Which application paths candidates may use for this role — web chat and/or the agent API surfaces. Omit to use the DB default (all four modes); a present array must be non-empty."},"weight_adjustments":{"type":"object","additionalProperties":{"type":"number"},"description":"Per-dimension weight overrides. Keys must be a subset of [grounding, causal, tradeoff, ownership, robustness]; each value must be a finite number in [0, 5]. A bad shape is rejected with 422."},"idempotency_key":{"type":"string","minLength":8,"maxLength":128,"description":"Optional key for safe retry. A repeat POST with the same key for this company returns the existing role (with idempotent_replay true) instead of creating a duplicate."}}}}}},"responses":{"201":{"description":"Created role. Returns the role record alongside its billing\nstatus, the number of trial evaluations still available, and the\nmachine-readable `skill.md` URL for the new role.\n","content":{"application/json":{"schema":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/Role"},"role_status":{"type":"string","description":"Billing status of the new role. New roles start in `trial`.","example":"trial"},"trial_evals_remaining":{"type":"integer","description":"Trial evaluations still available (3 minus the role's eval_count).","example":3},"skill_md_url":{"type":"string","format":"uri","description":"Absolute URL serving the role's machine-readable skill.md."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"A role with the generated slug already exists for this company.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"slug":{"type":"string"}}}}}},"422":{"description":"Validation failure on a present field: title too long or\nunsluggable (empty/reserved), bad `weight_adjustments` shape, or a\nmalformed `idempotency_key`.\n","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}}}}},"/api/v1/company/{slug}/roles/{roleId}":{"get":{"tags":["Roles"],"operationId":"getRole","summary":"Get a single role by id or slug.","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/RoleId"}],"responses":{"200":{"description":"Role detail.","content":{"application/json":{"schema":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/Role"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Roles"],"operationId":"updateRole","summary":"Patch role fields.","description":"Whitelisted fields: `title`, `description`, `level`,\n`ai_native_required`, `archetype_slug`, `weight_adjustments`,\n`must_haves`, `nice_to_haves`, `custom_notes`, `seniority_level`,\n`requirements`, `github_check_enabled`, `ai_assistance_check`,\n`location`, `compensation`, `page_template`,\n`work_arrangement`, `location_country`, `location_region`,\n`location_locality`, `remote_countries`, `allowed_modes`.\n\nEVI-448: the five structured-location fields are the only input to the\njob-board structured data on the public role page — a worksite address\nfor an on-site or hybrid role, a telecommute marker plus the\napplicant-location countries for a remote one. Nothing is inferred\nfrom the free-text `location` line, and no country is ever defaulted.\nAll five are optional and nullable; send `null` (or an empty string /\nempty array) to clear one. A `remote` role with no country at all —\nneither `remote_countries` nor `location_country` — publishes no\ntelecommute marker, because a posting that never says where the work\nmay be done is not placeable by a job board.\n\nEVI-445: the response also carries `role_version` and\n`requirements_stale`. When `requirements_stale` is true the\ndescription changed after the requirement areas were read from it;\npreview a re-read with `POST …/requirements/rederive` and apply the\nconfirmed set with `PUT …/requirements`.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/RoleId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"level":{"type":"string"},"ai_native_required":{"type":"boolean"},"archetype_slug":{"type":"string"},"weight_adjustments":{"type":"object","additionalProperties":{"type":"number"}},"must_haves":{"type":"array","items":{"type":"string"}},"nice_to_haves":{"type":"array","items":{"type":"string"}},"custom_notes":{"type":"string"},"seniority_level":{"type":"string"},"requirements":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequirement"}},"github_check_enabled":{"type":"boolean","nullable":true},"ai_assistance_check":{"type":"string","enum":["auto","required","skip"]},"location":{"type":"string","maxLength":200,"nullable":true},"compensation":{"type":"string","maxLength":200,"nullable":true},"work_arrangement":{"type":"string","enum":["remote","hybrid","onsite"],"nullable":true,"description":"EVI-448. How the job is done. The ONLY source of the telecommute marker on the public posting — it is never inferred from the free-text `location` line."},"location_country":{"type":"string","pattern":"^[A-Z]{2}$","nullable":true,"description":"EVI-448. ISO-3166-1 alpha-2 country of the worksite. A job board cannot place an address without it, and it is never defaulted."},"location_region":{"type":"string","maxLength":120,"nullable":true,"description":"EVI-448. State / province of the worksite."},"location_locality":{"type":"string","maxLength":120,"nullable":true,"description":"EVI-448. City / town of the worksite."},"remote_countries":{"type":"array","maxItems":20,"nullable":true,"items":{"type":"string","pattern":"^[A-Z]{2}$"},"description":"EVI-448. ISO-3166-1 alpha-2 codes an applicant may LIVE in. Used only when `work_arrangement` is `remote`; this is not the office."},"page_template":{"type":"string","enum":["plain","field_map","ledger","deck","dossier"],"description":"Layout of the public candidate role page (\"Posting page\"). Presentation only — never affects the evaluation or the score."},"allowed_modes":{"type":"array","minItems":1,"items":{"type":"string","enum":["api","poll","web","webhook"]},"description":"EVI-426/448. Which application paths candidates may use for this role — web chat and/or the agent API surfaces. A present array must be non-empty; a missing key leaves the current gate unchanged."}},"additionalProperties":false}}}},"responses":{"200":{"description":"Updated role.","content":{"application/json":{"schema":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/Role"},"role_version":{"type":"integer"},"requirements_stale":{"type":"boolean"},"requirements_stale_at":{"type":"string","format":"date-time","nullable":true},"requirements_source_version":{"type":"integer","nullable":true}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/roles/{roleId}/requirements/rederive":{"post":{"tags":["Roles"],"operationId":"rederiveRoleRequirements","summary":"Preview a re-read of the job description (writes nothing).","description":"EVI-445. Runs the same extractor the wizard uses against the role's\ncurrent title/description and returns the proposed requirement areas\nnext to the stored ones, with a diff. Nothing is written — apply with\n`PUT …/requirements` carrying the `role_version` returned here.\nRate limit: 20 per company per 5 minutes, shared with `recommend_role`.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/RoleId"}],"responses":{"200":{"description":"Proposed requirements and the diff against the stored set.","content":{"application/json":{"schema":{"type":"object","properties":{"role_version":{"type":"integer"},"requirements_stale_at":{"type":"string","format":"date-time","nullable":true},"requirements_source_version":{"type":"integer"},"current":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequirement"}},"proposed":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequirement"}},"diff":{"type":"object","properties":{"added":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequirement"}},"removed":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequirement"}},"changed":{"type":"array","items":{"type":"object"}},"unchanged":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequirement"}}}},"counts":{"type":"object","properties":{"added":{"type":"integer"},"removed":{"type":"integer"},"changed":{"type":"integer"},"unchanged":{"type":"integer"}}},"cache_hit":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"The role has no description to read from."},"429":{"description":"Rate limit exceeded (`Retry-After` header)."},"502":{"description":"Recommendation service temporarily unavailable."}}}},"/api/v1/company/{slug}/roles/{roleId}/requirements":{"put":{"tags":["Roles"],"operationId":"replaceRoleRequirements","summary":"Replace the requirement areas with a reviewed set.","description":"EVI-445. Optimistic concurrency on `expected_role_version` — the\nversion the preview was computed against. If the role changed in\nbetween, 409 `role_changed` with the current `role_version`; re-run\nthe preview. A set identical to what is stored only clears the stale\nflag. Audited as `role.requirements.updated` (counts only).\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/RoleId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["requirements","expected_role_version"],"properties":{"requirements":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequirement"}},"expected_role_version":{"type":"integer","minimum":1},"source":{"type":"string","enum":["rederive","manual"]}}}}}},"responses":{"200":{"description":"Requirements replaced (or confirmed unchanged).","content":{"application/json":{"schema":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/Role"},"role_version":{"type":"integer"},"requirements_stale":{"type":"boolean"},"noop":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The role changed since the preview; body carries the current `role_version`."}}}},"/api/v1/company/{slug}/roles/{roleId}/deactivate":{"post":{"tags":["Roles"],"operationId":"deactivateRole","summary":"Deactivate a role (stops accepting applications).","description":"Destructive. Sets `role.active = false`.","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/RoleId"}],"responses":{"200":{"description":"Role deactivated.","content":{"application/json":{"schema":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/Role"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/roles/{roleId}/active":{"post":{"tags":["Roles"],"operationId":"setRoleActive","summary":"Publish or unpublish a role (toggle role.active).","description":"Toggles `role.active`. This is the route the `publish_role` MCP tool\ncalls (with `{ active: true }`) to make a draft role visible to\ncandidates, and the inverse of `deactivate` for unpausing. Dual-auth:\nBearer API key or admin+ session cookie. Body `active` is required.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/RoleId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["active"],"properties":{"active":{"type":"boolean","description":"true publishes the role; false pauses it."}}}}}},"responses":{"200":{"description":"Role active flag updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"active":{"type":"boolean","description":"The role's new active state."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/roles/{roleId}/activate-plan":{"post":{"tags":["Billing"],"operationId":"activateRolePlan","summary":"Activate a role plan using a stored payment method.","description":"One-click activation against a saved card. Returns\n`{ status: \"activated\" }` on success. If no payment method is on file\nor 3DS is required, returns\n`{ status: \"requires_setup\", checkout_url }` — the hiring manager\nmust open `checkout_url` in a browser to save a card first.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/RoleId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string","enum":["full_access","pay_per_eval"]}}}}}},"responses":{"200":{"description":"Activation result.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"status":{"type":"string","enum":["activated"]}}},{"type":"object","properties":{"status":{"type":"string","enum":["requires_setup"]},"checkout_url":{"type":"string","format":"uri"}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/candidates":{"get":{"tags":["Candidates"],"operationId":"listCandidates","summary":"List candidates (paginated, never includes PII).","description":"NEVER returns `display_name` or `contact_email`, regardless of a\ncandidate's lead-unlock state. Use `get_candidate` (GET\n/candidates/{candidateAgentId}) to retrieve PII for an individual\nunlocked candidate. The list/single physical disjointness prevents\nbulk-dump of PII via list responses.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"unlocked_only","in":"query","required":false,"description":"When `true`, restricts results to candidates with a matching `lead_unlocks` row (candidates for whom the $100 lead unlock has been purchased).","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Paginated candidates.","content":{"application/json":{"schema":{"type":"object","properties":{"candidates":{"type":"array","items":{"$ref":"#/components/schemas/CandidateSummary"}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/candidates/{candidateAgentId}":{"get":{"tags":["Candidates"],"operationId":"getCandidate","summary":"Get a single candidate (PII-gated).","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/CandidateAgentId"}],"responses":{"200":{"description":"Candidate detail.","content":{"application/json":{"schema":{"type":"object","properties":{"candidate":{"$ref":"#/components/schemas/Candidate"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/candidates/{candidateAgentId}/decision":{"post":{"tags":["Candidates"],"operationId":"recordDecision","summary":"Record a hiring decision for a candidate.","description":"Timestamped server-side. Idempotent: re-submitting the same decision\nthat is already recorded is a 200 no-op and the response carries\n`idempotent: true`.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"$ref":"#/components/parameters/CandidateAgentId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["decision"],"properties":{"decision":{"type":"string","enum":["advance","reject","hold"]}}}}}},"responses":{"200":{"description":"Decision recorded (or idempotent no-op when the same decision was\nalready on file). `idempotent` is present and `true` only on the\nno-op path.\n","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"decision":{"type":"string","enum":["advance","reject","hold"]},"candidate_agent_id":{"type":"string","format":"uuid"},"idempotent":{"type":"boolean","description":"Present and `true` only when the decision was already recorded (no state change)."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/slack/configure":{"patch":{"tags":["Slack"],"operationId":"configureSlack","summary":"Configure Slack notification routing.","description":"Requires Slack to already be connected via the dashboard OAuth flow.","parameters":[{"$ref":"#/components/parameters/Slug"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["channel_id","channel_name","notify_mode"],"properties":{"channel_id":{"type":"string"},"channel_name":{"type":"string"},"notify_mode":{"type":"string","enum":["all","threshold"]},"score_threshold":{"type":"number","minimum":0,"maximum":100}}}}}},"responses":{"200":{"description":"Slack config updated."},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/api-key/rotate":{"post":{"tags":["API key"],"operationId":"rotateApiKey","summary":"Rotate the company API key.","description":"Destructive. Invalidates the current API key immediately. The\nresponse contains the new raw key EXACTLY ONCE — store it now.\n","parameters":[{"$ref":"#/components/parameters/Slug"}],"responses":{"200":{"description":"New raw API key (returned once).","content":{"application/json":{"schema":{"type":"object","properties":{"api_key":{"type":"string","examples":["evidal_sk_REDACTED"]},"key_prefix":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/dns-status":{"get":{"tags":["DNS"],"operationId":"checkDns","summary":"Read DNS verification status for the company custom domain.","description":"Runs a live CNAME **and** TXT lookup and sets `dns_verified` only when\nboth resolve. The shared CNAME target alone is not proof of ownership —\nevery Evidal customer points at it — so verification also requires a\ncompany-specific TXT record at `expected_txt_name` carrying\n`expected_txt_value`. Already-verified companies are grandfathered:\nthis endpoint never un-verifies.\n\nNOT side-effect free: a successful check flips `dns_verified`, can\npromote a draft slug, writes a `dns.verify_attempt` audit row and\nregisters the hostname with the hosting provider. Call it on an\nexplicit \"check my DNS now\", not on every page view.\n","parameters":[{"$ref":"#/components/parameters/Slug"}],"responses":{"200":{"description":"DNS status.","content":{"application/json":{"schema":{"type":"object","properties":{"dns_verified":{"type":"boolean"},"cname_found":{"type":"string","nullable":true},"points_to_evidal":{"type":"boolean"},"expected_cname":{"type":"string"},"accepted_cnames":{"type":"array","items":{"type":"string"},"description":"EVI-437: every CNAME target the check accepts — expected_cname first, then legacy aliases (cname.<base>, cname.vercel-dns.com)."},"legacy_cname":{"type":"boolean","description":"EVI-437: true when the CNAME found is a legacy alias — still valid, no change needed."},"txt_found":{"type":"boolean"},"expected_txt_name":{"type":"string","nullable":true},"expected_txt_value":{"type":"string"},"missing_records":{"type":"array","items":{"type":"string","enum":["cname","txt"]},"description":"Which of the two records is still absent."},"live":{"type":"boolean","description":"EVI-433: true only once https://<domain>/ answers over TLS."},"hosting_verification":{"type":"object","description":"EVI-438: the hosting provider's own ownership check, read live while the domain is verified but not yet live. When the customer's apex is already registered to another account at the provider (reason `other_account_owns_apex`), ONE more one-time TXT record (`_vercel.<apex>` = `vc-domain-verify=…`) is required; `records` lists it with the provider's OWN record type (only while required && !verified). Any other outstanding challenge reads as reason `hosting_record_required` — the record is still in `records`, only the stated cause is neutral. `attached:false` with reason `not_attached` means the hostname is not on the provider yet. `reason: provider_unreachable` means we could not reach the provider at all: `attached`/`verified` are UNKNOWN in that state, NOT false — never tell the customer their domain is not connected on the strength of it. Re-calling this endpoint asks the provider to re-verify.","properties":{"attached":{"type":"boolean"},"required":{"type":"boolean"},"verified":{"type":"boolean"},"reason":{"type":"string","nullable":true,"enum":["other_account_owns_apex","hosting_record_required","not_attached","provider_unreachable",null]},"records":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"name":{"type":"string"},"value":{"type":"string"},"reason":{"type":"string"}}}}}},"next_step":{"type":"string","nullable":true,"description":"EVI-438: one plain-English sentence to relay to the customer — the extra TXT record when one is required (its value is double-quoted inside the sentence; copy the exact value from `records[]`), the not-yet-connected wait with its support escalation (`not_attached`), the neutral wait when the provider is unreachable, or the certificate-issuing wait. Null when nothing is pending on the hosting side."},"next_step_kind":{"type":"string","nullable":true,"enum":["hosting_record","not_attached","cert_pending","provider_unreachable",null],"description":"EVI-438: which sentence `next_step` is, as a stable token — branch on this instead of parsing the prose. Null exactly when `next_step` is null."},"last_checked":{"type":"string","format":"date-time"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/billing/status":{"get":{"tags":["Billing"],"operationId":"getBillingStatus","summary":"Per-role and company billing state.","description":"Active, non-archived roles plus any whitelisted subscription or permanent company entitlement.","parameters":[{"$ref":"#/components/parameters/Slug"}],"responses":{"200":{"description":"Per-role billing state.","content":{"application/json":{"schema":{"type":"object","properties":{"roles":{"type":"array","items":{"type":"object","properties":{"role_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"title":{"type":"string"},"role_status":{"type":"string"},"eval_count":{"type":"integer"},"trial_remaining":{"type":"integer"},"needs_payment":{"type":"boolean","description":"True when the next evaluation for this role would be answered 402 — the same predicate the evaluator enforces, in the same order (subscription coverage → subscription role cap → per-role gate). A subscription-covered role never needs payment; a role over the subscription's role_cap ALWAYS does (blocked before the per-role gate, whatever its role_status); otherwise the per-role gate decides (unpaid, trial exhausted, pay-per-eval pack exhausted)."},"gate_reason":{"type":"string","nullable":true,"enum":["subscription_role_cap_exceeded","unpaid","trial_exhausted","pack_exhausted",null],"description":"Why the gate would block; null when evaluations run. subscription_role_cap_exceeded is not fixed by buying a pack — deactivate a role or upgrade the plan."},"evals_remaining":{"type":"integer","nullable":true,"description":"Evaluations left before the gate blocks (trial evals on trial, pack evals on pay_per_eval, 0 when blocked including over-cap). null = unlimited — full_access, or ANY subscription-covered role, because coverage bypasses the per-role gate (an exhausted pack on a covered role reads null here, not 0)."},"pack_remaining":{"type":"integer","nullable":true,"description":"Informational pack evaluations left on a pay_per_eval role, regardless of subscription coverage; null for other statuses."},"subscription_covered":{"type":"boolean"},"subscription_over_cap":{"type":"boolean"},"company_unlocked":{"type":"boolean","description":"True when a permanent company entitlement covers this role with unlimited evaluations."}}}},"lead_unlocks_count":{"type":"integer","description":"Total number of individual $100 candidate lead unlocks ever paid for this company. Company-wide PII access does not synthesize rows or inflate this count."},"subscription":{"type":"object","nullable":true,"description":"Whitelisted active subscription summary, or null.","additionalProperties":true},"company_access":{"type":"object","nullable":true,"description":"Permanent company entitlement, or null. Stripe IDs and the customer-facing code are never returned.","properties":{"scope":{"type":"string","enum":["all_roles","full_company"]},"status":{"type":"string","enum":["active"]},"permanent":{"type":"boolean","enum":[true]},"pii_access":{"type":"boolean","description":"True only for full_company, which includes all existing and future candidate PII."},"granted_at":{"type":"string","format":"date-time"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/unlocks":{"get":{"tags":["Billing"],"operationId":"listUnlocks","summary":"Paginated list of $100 candidate lead unlocks.","description":"Admin+ only for session callers (financial data is a strictly higher\nsensitivity class than the hiring-team roster). API-key callers have\nimplicit owner scope. Every call writes a `payment.lead_unlock.list`\naudit row.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Paginated lead unlocks.","content":{"application/json":{"schema":{"type":"object","properties":{"unlocks":{"type":"array","items":{"type":"object","properties":{"candidate_agent_id":{"type":"string","format":"uuid"},"candidate_agent_id_short":{"type":"string","description":"First 8 characters of candidate_agent_id (convenience for logging)."},"amount_cents":{"type":"integer"},"unlocked_at":{"type":"string","format":"date-time"},"stripe_payment_intent_id":{"type":"string","description":"Opaque Stripe identifier. Non-secret — cannot be acted on without the platform secret key."}}}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Session caller lacks admin role (viewers are rejected).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/payment/stripe/checkout":{"post":{"tags":["Billing"],"operationId":"startCheckout","summary":"Start a Stripe Checkout session for a role plan.","description":"Returns `{ checkout_url, session_id }`. The `checkout_url` must be\nopened in a browser — it cannot be completed programmatically.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["slug","plan"],"properties":{"slug":{"type":"string"},"plan":{"type":"string","enum":["full_access","pay_per_eval","lead_unlock"]},"role_id":{"type":"string","format":"uuid"},"candidate_agent_id":{"type":"string","format":"uuid"},"idempotency_key":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[A-Za-z0-9._-]{1,128}$","description":"Optional key for safe retry of a billable call. Forwarded to Stripe as the `Idempotency-Key` header (scoped to the company), so a repeat POST with the same key returns the SAME Checkout session instead of opening a second one. Rejected with 400 if it is not a string of 1-128 characters from [A-Za-z0-9._-]. The `start_checkout` MCP tool applies the same charset with a stricter 8-character minimum."}}}}}},"responses":{"200":{"description":"Stripe Checkout session.","content":{"application/json":{"schema":{"type":"object","properties":{"checkout_url":{"type":"string","format":"uri"},"session_id":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/members":{"get":{"tags":["Members"],"operationId":"listMembers","summary":"List hiring-team members and pending invites (paginated).","description":"Returns an array of current accepted members (`status: active`) and\noutstanding invites (`status: pending`). The `email` field is the\nteam-member email and should be treated as confidential — this is NOT\ncandidate PII, but it is internal company data.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Paginated members list.","content":{"application/json":{"schema":{"type":"object","properties":{"members":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":["string","null"],"format":"uuid"},"email":{"type":"string","format":"email"},"role":{"type":"string","enum":["owner","admin","viewer"]},"status":{"type":"string","enum":["active","pending"]},"invited_at":{"type":"string","format":"date-time"},"accepted_at":{"type":["string","null"],"format":"date-time"}}}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/members/invite":{"post":{"tags":["Members"],"operationId":"inviteMember","summary":"Invite a teammate by email.","description":"Sends an invite email with a secure token. Idempotent: if the email\nalready has a pending invite or is an active member, returns `200`\nwithout resending. API-key callers have implicit owner scope and may\ninvite at any role.\n","parameters":[{"$ref":"#/components/parameters/Slug"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","role"],"properties":{"email":{"type":"string","format":"email"},"role":{"type":"string","enum":["owner","admin","viewer"]},"assigned_role_ids":{"type":"array","maxItems":100,"items":{"type":"string","format":"uuid"},"description":"EVI-479 — pre-assign the invited hiring_manager/viewer to these roles (400 with owner/admin invites). Assignments survive invite acceptance and claim-by-email binding."}}}}}},"responses":{"200":{"description":"Invite created (or idempotent no-op).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/v1/company/{slug}/members/{userId}":{"delete":{"tags":["Members"],"operationId":"removeMember","summary":"Remove a teammate.","description":"Destructive. Last-owner removal is blocked (returns 400).\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"userId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Member removed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/members/{userId}/role":{"post":{"tags":["Members"],"operationId":"updateMemberRole","summary":"Change a teammate's role.","description":"Last-owner demotion is blocked (returns 400).\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"userId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["role"],"properties":{"role":{"type":"string","enum":["owner","admin","viewer"]}}}}}},"responses":{"200":{"description":"Role updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/members/{userId}/assignments":{"put":{"tags":["Members"],"operationId":"setMemberRoleAssignments","summary":"Replace a teammate's role assignments (EVI-479).","description":"Strict per-role visibility scope for the `hiring_manager` and `viewer`\ntiers: those members see ONLY roles assigned to them, and an empty\nassignment set means they see nothing. `owner`/`admin` members are\nnever row-limited (targeting one returns 400). Replace-set semantics —\nthe supplied `role_ids` become the member's full assignment list, and\n`[]` clears it. Every `role_id` must belong to this company.\n\nAPI keys remain company-wide (owner-equivalent) and are themselves\nnever scoped by assignments.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"userId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["role_ids"],"properties":{"role_ids":{"type":"array","maxItems":100,"items":{"type":"string","format":"uuid"}}}}}}},"responses":{"200":{"description":"Assignments replaced.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"role_ids":{"type":"array","items":{"type":"string","format":"uuid"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/{slug}/invites/{inviteId}":{"delete":{"tags":["Members"],"operationId":"revokeInvite","summary":"Revoke a pending invite.","description":"`inviteId` is the `company_members` row UUID (not the secret token).\nReturns 400 if the invite has already been accepted.\n","parameters":[{"$ref":"#/components/parameters/Slug"},{"name":"inviteId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Invite revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/v1/company/onboarding/start":{"post":{"tags":["Onboarding"],"operationId":"startOnboarding","summary":"Start a company onboarding session.","description":"Unauthenticated. Creates a pending onboarding session and emails a\nmagic-link verification URL to `owner_email`. The caller polls\n`GET /api/v1/company/onboarding/{sessionId}` until `status` flips to\n`verified`, then POSTs `?action=complete` to create the company.\n\nRate limits: **5 requests/hour per IP**, **100 requests/hour global**\ncircuit breaker. Exceeding either returns `429` (IP) or `503` (global)\nwith a `retry_after_seconds` hint.\n","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["owner_email","company_name"],"properties":{"owner_email":{"type":"string","format":"email","example":"founder@example.evidal.ai","description":"Email address that will receive the magic-link. Becomes the first owner of the new company."},"company_name":{"type":"string","maxLength":200,"example":"Example Co"},"slug":{"type":"string","description":"Desired company slug. If omitted, derived from `company_name`.","example":"example-co"},"domain":{"type":"string","description":"Optional custom domain to attach (DNS verification happens later).","example":"example.com"},"invite_emails":{"type":"array","items":{"type":"string","format":"email"},"description":"Additional hiring-team members to invite when the session is completed.","example":["teammate@example.evidal.ai"]}},"additionalProperties":false}}}},"responses":{"201":{"description":"Session created; verification email dispatched.","content":{"application/json":{"schema":{"type":"object","properties":{"session_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending_email_verification"]},"verification_email_sent_to":{"type":"string","format":"email"},"expires_at":{"type":"string","format":"date-time"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/RateLimited"}}}},"/api/v1/company/onboarding/{sessionId}":{"get":{"tags":["Onboarding"],"operationId":"getOnboardingStatus","summary":"Poll the status of an onboarding session.","description":"Unauthenticated. Returns the current session status. Callers typically\npoll this until `status === 'verified'`, then POST `?action=complete`.\n\nRate limit: **60 requests/hour per IP**.\n","security":[],"parameters":[{"$ref":"#/components/parameters/OnboardingSessionId"}],"responses":{"200":{"description":"Session status.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["pending_email_verification","verified","completed","cancelled","expired"]},"company_slug":{"type":"string","nullable":true,"description":"Present only when `status === 'completed'`."}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"tags":["Onboarding"],"operationId":"completeOnboarding","summary":"Complete a verified onboarding session (create the company).","description":"Unauthenticated. Exchanges a `verified` onboarding session for a new\ncompany record, issues the first `evidal_sk_` API key, and dispatches\nany queued invite emails.\n\nThe POST method on this path is routed by the required `action` query\nparam: `?action=complete` finalises the session (this operation);\n`?action=cancel` (see `cancelOnboarding`) terminates it.\n","security":[],"parameters":[{"$ref":"#/components/parameters/OnboardingSessionId"},{"name":"action","in":"query","required":true,"description":"Must be `complete` for this operation.","schema":{"type":"string","enum":["complete"]}}],"responses":{"200":{"description":"Company created.","content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string"},"api_key":{"type":"string","description":"The first `evidal_sk_` API key. Shown exactly once — store it immediately."},"admin_url":{"type":"string","format":"uri"},"subdomain_url":{"type":"string","format":"uri"},"docs_url":{"type":"string","format":"uri"},"mcp_endpoint":{"type":"string","format":"uri"},"cname_target":{"type":"string"},"invites":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string","format":"email"},"status":{"type":"string"},"error_message":{"type":"string","nullable":true}}}},"next_steps":{"type":"array","items":{"type":"string"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Slug already taken or session already completed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/ServerError"}}}},"/api/v1/company/onboarding/{sessionId}#cancel":{"post":{"tags":["Onboarding"],"operationId":"cancelOnboarding","summary":"Cancel an onboarding session.","description":"Unauthenticated. Terminates the session. Idempotent — re-calling on\nan already-terminal session returns the current status without error.\n\nNOTE: the actual request URL is `/api/v1/company/onboarding/{sessionId}`\n(no `#cancel` fragment — that is only a documentation anchor to\ndisambiguate this operation from `completeOnboarding`). The method is\n`POST` and the `action=cancel` query param is required.\n","security":[],"parameters":[{"$ref":"#/components/parameters/OnboardingSessionId"},{"name":"action","in":"query","required":true,"description":"Must be `cancel` for this operation.","schema":{"type":"string","enum":["cancel"]}}],"responses":{"200":{"description":"Session cancelled (or was already in a terminal state).","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["cancelled","completed","expired"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"410":{"description":"Session already expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/candidate/erase":{"post":{"tags":["Candidate self-service"],"operationId":"eraseCandidateData","summary":"Permanently erase all of a candidate's data (GDPR right to erasure).","description":"Hard-deletes everything Evidal holds about a candidate — applications,\nevaluation sessions and transcripts, scores, unlock records, and the\ncandidate account itself, across every company they applied to. The\ndeletion is immediate and irreversible.\n\n**Authentication (no Bearer API key):**\n- **Candidate self-service** — present a verified candidate session\n  (`evi_cand_session` HttpOnly cookie, or `Authorization: Bearer\n  <candidate-session JWT>`). The target candidate is taken from the\n  verified token, never from the body; a body `candidate_id` that\n  does not match the token returns `403`.\n- **Evidal staff (DSAR fulfilment)** — a staff Supabase session may\n  target any candidate by `candidate_id` or `contact_email`.\n\nCompany API keys are deliberately rejected: erasure is global across\ncompanies, so only the candidate themself or Evidal ops may trigger\nit.\n\n**Idempotent** — erasing an already-erased (or unknown) candidate\nreturns `200` with `already_erased: true` rather than `404`, to avoid\nan enumeration oracle and keep DSAR tooling retry-safe.\n\nRate limit: **10 requests/minute per IP**.\n","security":[],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"candidate_id":{"type":"string","format":"uuid","description":"Target candidate (staff callers only; for candidate-session callers it must match the token's candidate)."},"contact_email":{"type":"string","format":"email","description":"Alternative staff-only target selector (DSAR-by-email)."}},"additionalProperties":false}}}},"responses":{"200":{"description":"Erasure completed (or there was nothing left to erase).","content":{"application/json":{"schema":{"type":"object","properties":{"erased":{"type":"boolean"},"already_erased":{"type":"boolean","description":"True when no data remained for the target."},"deleted_counts":{"type":"object","description":"Rows deleted per table.","additionalProperties":{"type":"integer"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Candidate-session caller named a different candidate_id than their own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"Partial failure — some tables could not be deleted. Safe to retry (idempotent).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"BearerApiKey":{"type":"http","scheme":"bearer","bearerFormat":"evidal_sk_...","description":"Company-scoped API key generated at\n`/admin/{company}/integrations`. Pass as\n`Authorization: Bearer evidal_sk_...`.\n"}},"parameters":{"Slug":{"name":"slug","in":"path","required":true,"description":"Company slug.","schema":{"type":"string"}},"RoleId":{"name":"roleId","in":"path","required":true,"description":"Role UUID or role slug.","schema":{"type":"string"}},"CandidateAgentId":{"name":"candidateAgentId","in":"path","required":true,"description":"Candidate-agent UUID.","schema":{"type":"string","format":"uuid"}},"OnboardingSessionId":{"name":"sessionId","in":"path","required":true,"description":"Onboarding session UUID returned by `startOnboarding`.","schema":{"type":"string","format":"uuid"}}},"responses":{"BadRequest":{"description":"Invalid request parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing, invalid, or expired API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ServerError":{"description":"Unexpected server error. Body includes an `errorId` for correlation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Rate limit exceeded. `retry_after_seconds` indicates when to try again.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"retry_after_seconds":{"type":"integer"}}}}}}},"schemas":{"Company":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"domain":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"logo_url":{"type":"string","format":"uri","nullable":true},"logo_icon_url":{"type":"string","format":"uri","nullable":true},"logo_wordmark_url":{"type":"string","format":"uri","nullable":true},"dns_verified":{"type":"boolean"},"notification_email":{"oneOf":[{"type":"null"},{"type":"array","items":{"type":"string","format":"email"}}]}}},"RoleRequirement":{"type":"object","description":"One requirement area (`roles.requirements[]`, DB-canonical shape).\nShown to candidates on the role page and in skill.md as \"What this\nrole needs\"; read by the interview bot and the scorer.\n","required":["name","importance","type"],"properties":{"name":{"type":"string"},"importance":{"type":"string","enum":["core","important","informational"]},"type":{"type":"string","enum":["foundational","emerging","contextual"]},"notes":{"type":"string"},"usage_context":{"type":"string"},"acceptable_evidence":{"type":"array","items":{"type":"string"}},"archetype_links":{"type":"array","items":{"type":"string"}},"confirmed_by_employer":{"type":"boolean"}}},"Role":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"title":{"type":"string"},"level":{"type":"string"},"seniority_level":{"type":"string","nullable":true,"enum":["L1_Assisted_Contributor","L2_Independent_Contributor","L3_End_to_End_Owner","L4_System_Domain_Lead","L5_Strategic_Principal"],"description":"EVI-448 (G7). The canonical seniority enum, settable via create_role / update_role's `seniority_level`. When create_role omits it, the API-key create route derives it from the free-text `level` (e.g. \"Senior\" -> L3_End_to_End_Owner) rather than leaving it at the column's DB default."},"active":{"type":"boolean"},"role_status":{"type":"string"},"ai_native_required":{"type":"boolean"},"eval_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"location":{"type":"string","nullable":true},"compensation":{"type":"string","nullable":true},"allowed_modes":{"type":"array","nullable":true,"items":{"type":"string","enum":["api","poll","web","webhook"]},"description":"EVI-426 / EVI-448 (G7). Per-role application-mechanism gate, enforced in POST /api/v1/session/start. Default is all four modes. Settable via `update_role` / `create_role` and the dashboard's role configure page — all three run the same validator."},"work_arrangement":{"type":"string","enum":["remote","hybrid","onsite"],"nullable":true,"description":"EVI-448. How the job is done. Together with the four fields below it is the structured location the public role page publishes as job-board data; `location` above stays display copy."},"location_country":{"type":"string","pattern":"^[A-Z]{2}$","nullable":true},"location_region":{"type":"string","nullable":true},"location_locality":{"type":"string","nullable":true},"remote_countries":{"type":"array","nullable":true,"items":{"type":"string","pattern":"^[A-Z]{2}$"}},"requirements":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/RoleRequirement"}},"role_version":{"type":"integer"},"requirements_stale_at":{"type":"string","format":"date-time","nullable":true},"requirements_source_version":{"type":"integer"},"updated_at":{"type":"string","format":"date-time"},"page_template":{"type":"string","enum":["plain","field_map","ledger","deck","dossier"],"description":"Layout of the public candidate role page (\"Posting page\"). Presentation only — never affects the evaluation or the score."}}},"CandidateSummary":{"type":"object","description":"Candidate record as returned by `list_candidates` (list-shape). NEVER\nincludes `display_name` or `contact_email` — the list endpoint is PII-\nfree regardless of the candidate's lead-unlock state (bulk-dump\nprevention). Use `get_candidate` to retrieve available PII for one\ncandidate at a time.\n","properties":{"id":{"type":"string","format":"uuid"},"role_id":{"type":"string","format":"uuid"},"role_slug":{"type":"string","nullable":true},"role_title":{"type":"string","nullable":true},"submission_mode":{"type":"string"},"completeness_score":{"type":"integer","nullable":true},"decision":{"type":"string","nullable":true,"enum":["advance","reject","hold",null]},"decision_at":{"type":"string","format":"date-time","nullable":true},"submitted_at":{"type":"string","format":"date-time","nullable":true},"eval_status":{"type":"string","nullable":true},"score":{"type":"object","nullable":true,"properties":{"total":{"type":"integer","nullable":true},"flagged":{"type":"boolean","nullable":true},"scored_at":{"type":"string","format":"date-time","nullable":true}}},"lead_unlocked":{"type":"boolean","description":"Backward-compatible PII-available predicate. True for an individual lead unlock or a full-company entitlement."},"pii_access_basis":{"type":"string","nullable":true,"enum":["lead_unlock","company_unlock",null],"description":"Why candidate PII is available; null when it remains locked."}}},"Candidate":{"type":"object","description":"Candidate record as returned by `get_candidate` (single-candidate\nshape). Includes `display_name` and `contact_email` ONLY when PII is\navailable through an individual lead unlock or full-company entitlement.\nWhen locked, those\nkeys are ABSENT from the response (not null). Every PII-returning\nresponse writes one `candidate.pii_read` audit row with non-PII\nmetadata (candidate_agent_id, lead_unlock_id, auth_type,\napi_key_prefix).\n","properties":{"id":{"type":"string","format":"uuid"},"role_id":{"type":"string","format":"uuid"},"role_slug":{"type":"string","nullable":true},"role_title":{"type":"string","nullable":true},"submission_mode":{"type":"string"},"completeness_score":{"type":"integer","nullable":true},"decision":{"type":"string","nullable":true,"enum":["advance","reject","hold",null]},"decision_at":{"type":"string","format":"date-time","nullable":true},"submitted_at":{"type":"string","format":"date-time","nullable":true},"eval_status":{"type":"string","nullable":true},"score":{"type":"object","nullable":true,"properties":{"total":{"type":"integer","nullable":true},"flagged":{"type":"boolean","nullable":true},"scored_at":{"type":"string","format":"date-time","nullable":true},"signal_scores":{"type":"object","nullable":true,"description":"EVI-262 signal trace — one key per signal id with {score:0|0.5|1|null, evidence:string}.","additionalProperties":true},"dimension_levels":{"type":"object","nullable":true,"description":"EVI-262 per-dimension levels {numeric_0_to_1, level, final} where final applies any guardrail cap.","additionalProperties":true},"guardrail_overrides":{"type":"object","nullable":true,"description":"EVI-262 map of dimension key to fired guardrail IDs that lowered the level.","additionalProperties":true}}},"lead_unlocked":{"type":"boolean","description":"Backward-compatible PII-available predicate. True for an individual lead unlock or a full-company entitlement."},"lead_unlocked_at":{"type":"string","format":"date-time","nullable":true,"description":"ISO timestamp of the individual lead unlock or full-company PII grant; null when PII is locked."},"pii_access_basis":{"type":"string","nullable":true,"enum":["lead_unlock","company_unlock",null],"description":"Why candidate PII is available; null when it remains locked."},"display_name":{"type":"string","nullable":true,"description":"Candidate display name. Present only when PII is available. Absent otherwise."},"contact_email":{"type":"string","format":"email","nullable":true,"description":"Candidate contact email. Present only when PII is available. Absent otherwise."}}},"Error":{"type":"object","properties":{"error":{"type":"string"},"errorId":{"type":"string","description":"Correlation ID present on 5xx responses."}}}}}}