# ZenHire Speech Analysis API — full documentation bundle Generated: 2026-07-03T17:21:47.424Z Source: https://platform.zenhire.ai/docs This file contains the entire public documentation for the ZenHire Speech Analysis API, concatenated into a single plain-text bundle for ingestion by LLMs and AI coding assistants. Each section begins with a header indicating the source page and its URL. ================================================================================ ## Docs Source: https://platform.zenhire.ai/docs/ > Official documentation for the ZenHire API Platform. Speech proficiency analysis, CV DeepMatch, credits — quickstart guides, full API reference, and an LLM-ready bundle. # ZenHire API Platform The ZenHire API Platform is organized into four **modules** plus a set of **[Universal](/universal/overview)** concepts shared across all of them: - **[Speech Analysis](/speech/overview)** — CEFR-aligned vocabulary, fluency, and accent scores from a raw interview recording, plus a full transcript with speaker diarization and automatic candidate detection. - **[Interview](/interview/overview)** — real-time AI interview sessions. The AI conducts a live spoken interview, streams bidirectional audio, and produces a recording and transcript when the session ends. - **[CV DeepMatch](/cv-deepmatch/overview)** — structured match scoring between a candidate's CV and a job description. - **[CV DeepSearch](/cv-deepsearch/overview)** — the inverse of CV DeepMatch: ingest a corpus of parsed CVs once, then run repeated searches that return the best-matching candidates for a position. **Speech Analysis** is **asynchronous**: you submit audio, get an [`id`](/universal/identifiers) back immediately, then poll for results. Typical processing time is 2–5 minutes. **Interview** is **real-time**: sessions are driven over a live channel with sub-second AI response latency. **CV DeepMatch** is **asynchronous**: submit a CV + JD, poll (or get a signed webhook) for the match. **CV DeepSearch** is **asynchronous**: ingest a corpus, then submit a search and poll (or get a webhook) for the best-first candidates. Every module shares one set of **[Universal](/universal/overview)** rules — [authentication](/universal/authentication), the run [`id` model](/universal/identifiers), the [standard error envelope](/universal/errors), [credits](/universal/credits), and [health](/universal/health). Learn them once and they apply across all four. Across all modules, the public surface is request-only: API keys and **Projects** are created and managed in the [ZenHire dashboard](https://platform.zenhire.ai), not via the API. See [Authentication](/universal/authentication) for how keys and Projects work.
Docs

Quickstart, guides, and code examples — everything you need to integrate the ZenHire API end-to-end.

Read the guides →
API reference

Every endpoint, request, response, and error code — generated from the OpenAPI spec with an interactive "Try it" console.

Browse the reference →
## Speech Analysis endpoints Base URL: `https://platform.zenhire.ai` | Method | Path | What it does | | ------ | ---- | ------------ | | `POST` | `/api/v1/speech/analyze` | Submit an audio file — returns `id`. See [Quickstart](/get-started/quickstart). | | `GET` | `/api/v1/speech/analyze/{id}` | Poll for the result. See [Async polling flow](/guides/async-polling). | | `GET` | `/api/v1/speech/runs` | List your runs with optional filters. | For full end-to-end example code, see **[Your first request](/get-started/first-request)**. ## CV DeepMatch endpoints Base URL: `https://platform.zenhire.ai` | Method | Path | What it does | | ------ | ---- | ------------ | | `POST` | `/api/v1/cvdeepmatch/submit` | Submit a CV + JD — returns `id`. | | `GET` | `/api/v1/cvdeepmatch/{id}` | Poll a single match. | | `GET` | `/api/v1/cvdeepmatch/requests` | List your matches. | See the **[CV DeepMatch overview](/cv-deepmatch/overview)** to get started. ## CV DeepSearch endpoints Base URL: `https://platform.zenhire.ai` | Method | Path | What it does | | ------ | ---- | ------------ | | `POST` | `/api/v1/cvds/candidates` | Ingest parsed CVs into a corpus (idempotent batch upsert). | | `GET` | `/api/v1/cvds/candidates[/{external_id}]` | Check per-candidate embedding status. | | `POST` | `/api/v1/cvds/search` | Trigger a search for one position — returns a run `id`. | | `GET` | `/api/v1/cvds/runs/{id}` | Poll a search run. | See the **[CV DeepSearch overview](/cv-deepsearch/overview)** to get started. ## Universal endpoints Credits and health belong to no single module and live directly under `/api/v1`: | Method | Path | What it does | | ------ | ---- | ------------ | | `GET` | `/api/v1/credits` | Check your shared balance. See [Credits](/universal/credits). | | `GET` | `/api/v1/health` | Service status (no authentication). See [Health](/universal/health). | ## Interview API endpoints Base URL: `https://platform.zenhire.ai` Run AI voice interviews for your candidates — create a persona, start a per-candidate session, hand the candidate a single-use link, and review the recording + transcript in-platform. | Method | Path | What it does | | ------ | ---- | ------------ | | `POST` | `/api/v1/interview/personas` | Create a reusable interviewer persona (role + language). | | `GET` | `/api/v1/interview/personas` | List your personas. | | `POST` | `/api/v1/interview/personas/{personaId}/sessions` | Start a session — returns `id` and a candidate link. | | `GET` | `/api/v1/interview/sessions` | List your sessions. | | `GET` | `/api/v1/interview/sessions/{sessionId}` | Fetch session status and metadata. | | `GET` | `/api/v1/interview/sessions/{sessionId}/recording` | The session recording (streamed `audio/wav`). | | `GET` | `/api/v1/interview/sessions/{sessionId}/transcript` | The session transcript. | See **[Interview API overview](/interview/overview)** to get started. The full contract is also available as a raw OpenAPI 3.1 file: → **[Download openapi.yaml](pathname:///docs/openapi.yaml)** ## :robot: Building with AI? Download **[llms-full.txt](pathname:///docs/llms-full.txt)** — the entire documentation compiled into a single plain-text bundle you can paste into Claude, GPT, or any LLM as context. Regenerated on every docs build, so it's always in sync with the live API. There's also a short index at **[llms.txt](pathname:///docs/llms.txt)** if you only want the table of contents. -------------------------------------------------------------------------------- ## authentication Source: https://platform.zenhire.ai/docs/get-started/authentication/ --- id: authentication title: Authentication sidebar_position: 2 description: Authentication moved to the Universal section — API keys, key types, the X-API-Key header, Projects, and per-module permissions. --- # Authentication Authentication is the same across every ZenHire module, so it now lives in the **Universal** section: ➡️ **[Universal → Authentication](/universal/authentication)** There you'll find the `X-API-Key` header, dashboard-managed keys and Projects, per-module permissions (`speech` / `interview` / `cvdeepmatch` / `cvdeepsearch`), secure storage, and the auth error codes — all documented once. -------------------------------------------------------------------------------- ## first-request Source: https://platform.zenhire.ai/docs/get-started/first-request/ --- id: first-request title: Your first request sidebar_position: 3 description: A complete, production-grade integration example, using the Speech module to illustrate the universal async submit → poll pattern. --- # Your first request A complete end-to-end integration, showing all the pieces you'll need in production: 1. Submit the audio 2. Poll with escalating intervals 3. Handle queued / processing / terminal statuses 4. Respect `Retry-After` on 429s 5. Extract and use the scores :::note Speech module example This walks through a full integration using the **Speech** module. The same `X-API-Key` auth and async **submit → poll** pattern applies to every module — see [CV DeepMatch](/cv-deepmatch/overview), [CV DeepSearch](/cv-deepsearch/overview), and [Interview](/interview/quickstart) for their endpoints and specifics. ::: ## Full working example ```python import os import time import requests API_BASE = "https://platform.zenhire.ai" API_KEY = os.environ["ZENHIRE_API_KEY"] # zh_… AUDIO = "interview.mp3" def analyze(audio_path: str, external_id: str | None = None) -> dict: # 1. Submit with open(audio_path, "rb") as f: data = {"language": "en"} if external_id: data["externalId"] = external_id r = requests.post( f"{API_BASE}/api/v1/speech/analyze", headers={"X-API-Key": API_KEY}, files={"audio": f}, data=data, timeout=60, ) r.raise_for_status() submit = r.json() request_id = submit["id"] print(f"Submitted: {request_id} (status={submit['status']})") # 2. Poll start = time.time() interval = submit.get("pollIntervalSeconds", 15) while time.time() - start < 20 * 60: # 20-minute budget time.sleep(interval) resp = requests.get( f"{API_BASE}/api/v1/speech/analyze/{request_id}", headers={"X-API-Key": API_KEY}, timeout=30, ) if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", interval)) time.sleep(retry_after) continue resp.raise_for_status() data = resp.json() status = data["status"] if status in ("success", "partial"): return data if status == "failed": err = data.get("error") or {} raise RuntimeError(f"{err.get('code')}: {err.get('message')}") # Escalate: 15s for first 2 min, then 30s, then 60s elapsed = time.time() - start interval = 15 if elapsed < 120 else 30 if elapsed < 300 else 60 raise TimeoutError( f"Polling budget exhausted for {request_id} " "— the id is still valid, you can resume later." ) if __name__ == "__main__": result = analyze(AUDIO, external_id="candidate-abc-123") s = result["scores"] print(f"Overall: {s['overall']} ({s['cefrLevel']})") print(f" Vocab: {s['vocabulary']} ({s['vocabularyCefr']})") print(f" Fluency: {s['fluency']} ({s['fluencyCefr']})") print(f" Accent: {s['accent']} ({s['accentCefr']})") print(f"\n{result['hrRecommendation']}") ``` ```javascript const API_BASE = "https://platform.zenhire.ai"; const API_KEY = process.env.ZENHIRE_API_KEY; const AUDIO = "interview.mp3"; async function analyze(audioPath, externalId) { // 1. Submit const form = new FormData(); form.append("audio", new Blob([fs.readFileSync(audioPath)]), audioPath); form.append("language", "en"); if (externalId) form.append("externalId", externalId); const submitRes = await fetch(`${API_BASE}/api/v1/speech/analyze`, { method: "POST", headers: { "X-API-Key": API_KEY }, body: form, }); if (!submitRes.ok) { throw new Error(`Submit failed: ${submitRes.status}`); } const submit = await submitRes.json(); console.log(`Submitted: ${submit.id} (status=${submit.status})`); // 2. Poll const start = Date.now(); let interval = (submit.pollIntervalSeconds ?? 15) * 1000; while (Date.now() - start < 20 * 60_000) { await new Promise((r) => setTimeout(r, interval)); const res = await fetch( `${API_BASE}/api/v1/speech/analyze/${submit.id}`, { headers: { "X-API-Key": API_KEY } }, ); if (res.status === 429) { const retryAfter = parseInt(res.headers.get("Retry-After") ?? "15", 10); await new Promise((r) => setTimeout(r, retryAfter * 1000)); continue; } if (!res.ok) throw new Error(`Poll failed: ${res.status}`); const data = await res.json(); if (data.status === "success" || data.status === "partial") return data; if (data.status === "failed") { throw new Error(`${data.error?.code}: ${data.error?.message}`); } // Escalate: 15s → 30s → 60s const elapsed = Date.now() - start; interval = elapsed < 120_000 ? 15_000 : elapsed < 300_000 ? 30_000 : 60_000; } throw new Error("Polling budget exhausted"); } const result = await analyze(AUDIO, "candidate-abc-123"); const s = result.scores; console.log(`Overall: ${s.overall} (${s.cefrLevel})`); console.log(` Vocab: ${s.vocabulary} (${s.vocabularyCefr})`); console.log(` Fluency: ${s.fluency} (${s.fluencyCefr})`); console.log(` Accent: ${s.accent} (${s.accentCefr})`); console.log(`\n${result.hrRecommendation}`); ``` ```bash #!/usr/bin/env bash set -euo pipefail API_BASE="https://platform.zenhire.ai" API_KEY="${ZENHIRE_API_KEY:?set ZENHIRE_API_KEY}" AUDIO="interview.mp3" # 1. Submit SUBMIT=$(curl -sS -X POST "$API_BASE/api/v1/speech/analyze" \ -H "X-API-Key: $API_KEY" \ -F "audio=@$AUDIO" \ -F "language=en" \ -F "externalId=candidate-abc-123") REQUEST_ID=$(echo "$SUBMIT" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])") echo "Submitted: $REQUEST_ID" # 2. Poll INTERVAL=15 START=$(date +%s) while true; do sleep "$INTERVAL" NOW=$(date +%s) if (( NOW - START > 20 * 60 )); then echo "Polling budget exhausted" exit 1 fi RESP=$(curl -sS -w "\n%{http_code}" "$API_BASE/api/v1/speech/analyze/$REQUEST_ID" \ -H "X-API-Key: $API_KEY") HTTP_CODE=$(echo "$RESP" | tail -1) BODY=$(echo "$RESP" | sed '$d') if [[ "$HTTP_CODE" == "429" ]]; then sleep 15; continue fi STATUS=$(echo "$BODY" | python3 -c "import sys, json; print(json.load(sys.stdin)['status'])") case "$STATUS" in success|partial) echo "$BODY" | python3 -m json.tool exit 0 ;; failed) echo "Analysis failed: $BODY" exit 1 ;; esac ELAPSED=$(( $(date +%s) - START )) INTERVAL=$(( ELAPSED < 120 ? 15 : ELAPSED < 300 ? 30 : 60 )) done ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "strconv" "time" ) func main() { apiBase := "https://platform.zenhire.ai" apiKey := os.Getenv("ZENHIRE_API_KEY") // 1. Submit body := &bytes.Buffer{} w := multipart.NewWriter(body) f, _ := os.Open("interview.mp3") defer f.Close() part, _ := w.CreateFormFile("audio", "interview.mp3") io.Copy(part, f) w.WriteField("language", "en") w.WriteField("externalId", "candidate-abc-123") w.Close() req, _ := http.NewRequest("POST", apiBase+"/api/v1/speech/analyze", body) req.Header.Set("X-API-Key", apiKey) req.Header.Set("Content-Type", w.FormDataContentType()) resp, _ := http.DefaultClient.Do(req) var submit map[string]any json.NewDecoder(resp.Body).Decode(&submit) resp.Body.Close() requestID := submit["id"].(string) fmt.Println("Submitted:", requestID) // 2. Poll start := time.Now() interval := 15 * time.Second for time.Since(start) < 20*time.Minute { time.Sleep(interval) pollReq, _ := http.NewRequest("GET", apiBase+"/api/v1/speech/analyze/"+requestID, nil) pollReq.Header.Set("X-API-Key", apiKey) pollResp, _ := http.DefaultClient.Do(pollReq) if pollResp.StatusCode == 429 { retryAfter, _ := strconv.Atoi(pollResp.Header.Get("Retry-After")) if retryAfter == 0 { retryAfter = 15 } time.Sleep(time.Duration(retryAfter) * time.Second) pollResp.Body.Close() continue } var data map[string]any json.NewDecoder(pollResp.Body).Decode(&data) pollResp.Body.Close() status := data["status"].(string) if status == "success" || status == "partial" { b, _ := json.MarshalIndent(data, "", " ") fmt.Println(string(b)) return } if status == "failed" { fmt.Println("Failed:", data["error"]) os.Exit(1) } elapsed := time.Since(start) if elapsed < 2*time.Minute { interval = 15 * time.Second } else if elapsed < 5*time.Minute { interval = 30 * time.Second } else { interval = 60 * time.Second } } fmt.Println("Polling budget exhausted") } ``` ## What this example gets right - **Escalating poll intervals** — starts at 15s, backs off as the run takes longer. Don't hammer every 5 seconds. - **Handles 429 on poll** — respects the `Retry-After` header. - **Raises on `failed`, returns on `success`/`partial`** — both `success` and `partial` contain usable scores. - **Sets `externalId`** — your own [correlation tag](/universal/identifiers), echoed back on every response. Filterable on [`GET /api/v1/speech/runs`](/api/list-runs). Not unique, so safe to reuse across retries. - **Does not treat `queued` or `processing` as errors** — they're normal mid-run states. - **20-minute overall budget, then exits cleanly** — the run `id` does not expire, so you can resume later. ## Next - **[Async polling flow](/guides/async-polling)** — deeper discussion of intervals, back-off, and webhooks. - **[Error handling](/guides/error-handling)** — what to do on 402, 403, 503. - **[POST /api/v1/speech/analyze](/api/submit-speech-analysis)** — submit endpoint reference. - **[GET /api/v1/speech/analyze/\{id\}](/api/poll-speech-analysis)** — poll endpoint reference. - More recipes: [cURL](/code-examples/curl), [Python](/code-examples/python), [Node.js](/code-examples/node). - **Other modules:** [CV DeepMatch](/cv-deepmatch/overview) · [CV DeepSearch](/cv-deepsearch/overview) · [Interview](/interview/quickstart) — same async pattern, different endpoints. -------------------------------------------------------------------------------- ## quickstart Source: https://platform.zenhire.ai/docs/get-started/quickstart/ --- id: quickstart title: Quickstart sidebar_position: 1 description: Get started with the ZenHire API — get a key, learn the one shared request model, and pick your module. --- # Quickstart The ZenHire API is one platform with four modules — **CV DeepMatch**, **CV DeepSearch**, **Interview**, and **Speech** — behind a single base URL and a single auth model. This page gets you from zero to your first call, then points you at the module you need. **Base URL:** `https://platform.zenhire.ai/api/v1` ## 1. Get an API key Create an API key in the ZenHire dashboard. Keys look like `zh_api_…` and every request sends it as an `X-API-Key` header. Each key is scoped to your client and to the modules your client has enabled. See [Authentication](/universal/authentication) for rotation and per-module permissions. ## 2. Learn the shared model Every module works the same way, so what you learn once applies everywhere: - **One auth header** — `X-API-Key` on every request. - **Async submit → poll** — you submit a job and get back an `id`, then poll `GET …/{id}` until it reaches a terminal status (`success` / `partial` / `failed`). An optional `webhook_url` can deliver the result instead of polling. - **Universal concepts** — the [id model](/universal/identifiers), [error envelope](/universal/errors), [credits](/universal/credits), and [health](/universal/health) are identical across modules. Read them once in the [Universal](/universal/overview) section. ## 3. Pick your module | Module | What it does | Start here | |---|---|---| | **CV DeepMatch** | Score a CV against a job description. | [CV DeepMatch overview](/cv-deepmatch/overview) | | **CV DeepSearch** | Search a corpus of parsed CVs for the best-matching candidates against a position. | [CV DeepSearch overview](/cv-deepsearch/overview) | | **Interview** | Run an AI voice interview and retrieve the recording + transcript. | [Interview quickstart](/interview/quickstart) | | **Speech** | Score interview audio for vocabulary, fluency, and accent (CEFR). | [Speech overview](/speech/overview) | ## A first call (Speech example) Here is the submit → poll shape end to end, using the **Speech** module. CV DeepMatch, CV DeepSearch, and Interview follow the same pattern against their own endpoints. ```bash # Submit — returns an id curl -X POST "https://platform.zenhire.ai/api/v1/speech/analyze" \ -H "X-API-Key: zh_api_YOUR_KEY_HERE" \ -F "audio=@interview.mp3" \ -F "language=en" # Poll — until status is success / partial / failed curl "https://platform.zenhire.ai/api/v1/speech/analyze/req_1705412345678_abc123" \ -H "X-API-Key: zh_api_YOUR_KEY_HERE" ``` ```python import requests BASE = "https://platform.zenhire.ai/api/v1" KEY = "zh_api_YOUR_KEY_HERE" # Submit — returns an id with open("interview.mp3", "rb") as f: submit = requests.post( f"{BASE}/speech/analyze", headers={"X-API-Key": KEY}, files={"audio": f}, data={"language": "en"}, ).json() # Poll — until status is success / partial / failed result = requests.get( f"{BASE}/speech/analyze/{submit['id']}", headers={"X-API-Key": KEY}, ).json() print(result) ``` ```javascript const BASE = "https://platform.zenhire.ai/api/v1"; const KEY = "zh_api_YOUR_KEY_HERE"; // Submit — returns an id const form = new FormData(); form.append("audio", new Blob([fs.readFileSync("interview.mp3")]), "interview.mp3"); form.append("language", "en"); const submit = await (await fetch(`${BASE}/speech/analyze`, { method: "POST", headers: { "X-API-Key": KEY }, body: form, })).json(); // Poll — until status is success / partial / failed const result = await (await fetch(`${BASE}/speech/analyze/${submit.id}`, { headers: { "X-API-Key": KEY }, })).json(); console.log(result); ``` The `id` is the run's canonical [identifier](/universal/identifiers) and never expires — you can stop and resume polling later. Don't poll a given `id` faster than every 10 seconds. ## Next steps - **[Your first request](/get-started/first-request)** — a complete, production-grade Speech integration (escalating poll, 429 handling, retries). - **[Authentication](/universal/authentication)** — keys, rotation, permissions. - **[Universal concepts](/universal/overview)** — auth, ids, errors, credits, and health, shared by every module. - Module references: [CV DeepMatch](/cv-deepmatch/overview) · [CV DeepSearch](/cv-deepsearch/overview) · [Interview](/interview/quickstart) · [Speech](/speech/overview). -------------------------------------------------------------------------------- ## Async polling flow Source: https://platform.zenhire.ai/docs/guides/async-polling/ > How submit + poll works, recommended polling cadence, and how to design your integration. # Async polling flow The ZenHire API is **asynchronous**: submit returns immediately, and you poll for results. This page explains why, and how to design your integration around it. ## Why async? Speech analysis runs through several steps — transcription, speaker diarization, candidate detection, then three independent ML scoring pipelines (vocabulary, fluency, accent). Typical end-to-end latency is **2–5 minutes**, sometimes longer under load. A synchronous HTTP request would time out on most clients. The async flow lets you: - Submit thousands of audio files in parallel without blocking on any one. - Survive client-side restarts — the run [`id`](/universal/identifiers) never expires. - Safely retry a transient network error on the poll endpoint. ## The lifecycle ``` queued → processing → success | partial | failed ``` - **`queued`** — the request was accepted but no processing slot was available. The run starts automatically, in FIFO order, when a slot frees up. You see `queuePosition` in the response. - **`processing`** — actively running. `Retry-After` is set on the poll response. - **`success`** — all three scores produced. - **`partial`** — at least one score produced, others failed. Credits are charged proportionally. - **`failed`** — no scores produced. No credits charged. See `error.code` and `error.message`. ## Recommended poll cadence Use `pollIntervalSeconds` from the most recent response. If you want to implement your own strategy: | Elapsed since submit | Interval | |---|---| | 0–2 minutes | 15 seconds | | 2–5 minutes | 30 seconds | | 5+ minutes | 60 seconds | **Minimum interval per run `id` is 10 seconds.** Faster polls return `429 POLL_RATE_LIMITED` — respect the `Retry-After` header. ## When to stop polling Stop when the status is terminal (`success`, `partial`, or `failed`), or when your budget expires. **The run `id` never expires.** If your client crashes mid-poll, you can pick up the same `id` hours later and continue polling. ## Concurrency and queueing Default **8 simultaneous `processing` runs per client** (contact support to extend). When you reach that limit, new submissions return `202 Accepted` with `status: "queued"` — **not an error.** Queue position is included in the response. This means a successful `202` from submit doesn't guarantee your run has *started* — check the poll endpoint to know when `processing` begins. ## Explainability on the success response Successful and partial responses include an optional `explainability` object with plain-language, human-readable summaries of *why* each score came out the way it did: ```json { "explainability": { "vocab": "Advanced vocabulary with consistent use of C1+ words...", "fluency": "Fluid delivery at 156 words/min with a strong..." } } ``` Two sentences per dimension, safe to surface directly to end users (hiring managers, candidates, audit reviewers) — no post-processing required. Omitted on `failed` runs and in rare cases where a per-dimension feature breakdown couldn't be produced, so branch on presence before rendering. ## Poll-only across services This submit-and-poll pattern is the common shape across the platform. **Speech**, **CV DeepMatch**, and **CV DeepSearch** all support a poll-only integration: submit returns a run [`id`](/universal/identifiers), and you poll until a terminal state. No public endpoint is required. The same cadence rules apply to CV DeepMatch and CV DeepSearch — **minimum 10 seconds between polls per `id`**, with `429` + `Retry-After` on faster polls. See the [CV DeepMatch integration guide](/guides/cv-deepmatch) and [CV DeepSearch search guide](/guides/cv-deepsearch-search) for poll-vs-webhook details. ## Webhooks alternative? - **Speech API** — webhooks are not yet supported. If you need them, let your ZenHire contact know; it's on the roadmap. - **CV DeepMatch** and **CV DeepSearch** — webhooks *are* supported as an **optional** push channel: supply a `webhook_url` on submit to also receive a signed HMAC callback. Omit it to integrate poll-only. ## See in the API reference - **[POST /api/v1/speech/analyze](/api/submit-speech-analysis)** — submit audio, returns the run `id` - **[GET /api/v1/speech/analyze/\{id\}](/api/poll-speech-analysis)** — poll for results - **[GET /api/v1/speech/runs](/api/list-runs)** — list historical runs -------------------------------------------------------------------------------- ## Candidate detection Source: https://platform.zenhire.ai/docs/guides/candidate-detection/ > How ZenHire identifies the candidate in a multi-speaker interview recording. # Candidate detection Most interview recordings contain at least two speakers: an interviewer and a candidate. The scoring pipeline must know which speaker to score — otherwise you'd be scoring your recruiter's English instead of the candidate's. ## How it works After transcription and speaker diarization, the API inspects each speaker's segments and picks the **candidate** using: - Total speaking time (candidates typically speak more) - Nature of the speech (answering questions vs. asking them) - Word count and segment distribution The decision is returned on every successful poll response: ```json { "candidateDetection": { "speaker": "speaker_1", "speakerLabel": "Speaker 2", "confidence": "high", "reason": "Speaker answers questions and describes experience", "wordCount": 432 } } ``` ## Confidence levels | Confidence | When you see it | |---|---| | `high` | One speaker clearly dominates in candidate-like behavior. | | `medium` | Less clear — maybe the interview was very balanced. | | `low` | Ambiguous. Consider reviewing the transcript manually. | Scores are always produced regardless of confidence level, but `low` results deserve extra human review before being used in hiring decisions. ## Single-speaker recordings If the recording contains only one speaker (e.g., a pre-recorded monologue or screening answer), that speaker is scored. `speakerCount: 1` is returned. ## Minimum useful duration The pipeline needs at least **3 minutes** of total audio and a reasonable amount of candidate speech to produce reliable scores. Submissions under 3 minutes are rejected with `AUDIO_TOO_SHORT`. ## See in the API reference The `candidateDetection` object is returned on every successful poll response: - **[GET /api/v1/speech/analyze/\{id\}](/api/poll-speech-analysis)** — see the `candidateDetection` field in the 200 response schema -------------------------------------------------------------------------------- ## Credits Source: https://platform.zenhire.ai/docs/guides/credits/ > Credits moved to the Universal section — one shared ledger across every ZenHire API module. # Credits Credits are a single shared balance across every ZenHire module, so they now live in the **Universal** section: ➡️ **[Universal → Credits](/universal/credits)** There you'll find per-module pricing, when credits are deducted, the pre-submit minimum-balance check, how to check your balance via [`GET /api/v1/credits`](/api/get-credits), and how to top up — all documented once. -------------------------------------------------------------------------------- ## CV DeepMatch position config Source: https://platform.zenhire.ai/docs/guides/cv-deepmatch-position-config/ > How the requirements config is shaped — the 1–5 importance scale, where each requirement belongs, education ON/OFF, minimal vs preferable qualifications, and cardinality limits. # CV DeepMatch position config Every CV DeepMatch submission carries a `config` object whose `requirements` block tells the matching engine **what a good candidate looks like** for this position. The endpoint reference ([`CvDeepMatchRequirements`](/cv-deepmatch/schemas/cvdeepmatchrequirements)) lists every field and its type. This guide explains the *conventions* — the things that aren't obvious from the schema alone and that, if you get them wrong, produce a config that validates but scores badly. If you only read one thing: **every `importance` is an integer 1–5, and each kind of requirement has exactly one correct home.** The rest is detail. ## The 1–5 importance scale `importance` appears throughout the config — on the work-experience dimension, on each skill, on each industry, on education, and more. It is **always an integer from 1 to 5**: | Value | Meaning | |-------|---------| | `1` | Barely matters / nice-to-have | | `3` | Moderately important | | `5` | Critical to the role | Internally the engine divides the value by 5 to get a 0–1 weight, so `5 → 1.0`, `3 → 0.6`, `1 → 0.2`. That has consequences: - **`0` is not "off".** A `0` produces a zero weight — the dimension silently contributes nothing. To switch education off, use `is_enabled: false` (below), not `importance: 0`. - **Values above 5 overweight.** `importance: 7` yields a weight of `1.4`, which can push scores out of the expected range. - **Fractions are not the contract.** `4.5` happens to arithmetic-work but is not accepted — send whole integers 1–5. The submit endpoint rejects out-of-range or non-integer `importance` values with `INVALID_INPUT`, so you'll catch most mistakes at submit time rather than in a silently-wrong score. Still, choose deliberately: making everything `5` is the same as making everything `1` — nothing is prioritised relative to anything else. ## Field routing — what goes where Each requirement you extract from a job description has exactly one correct place. Putting it elsewhere doesn't error, but it asks the engine the wrong question. | Requirement | Goes in | Never in | |---|---|---| | A language (e.g. "English B2") | `skills.language_skills_config` | `hard_skills`, `soft_skills`, qualifications | | Years / months of experience | `workExperience.from` / `.to` | qualifications | | Degree or field of study | `education.academic` | skills or qualifications | | A tool / system / method visible on a CV | `skills_config.hard_skills` | qualifications | | A behavioural / interpersonal trait | `skills_config.soft_skills` | qualifications | | A related job title | `workExperience.relevant_roles` | skills | | A relevant industry | `workExperience.relevant_industries` (+ `industries_config`) | skills | | An essential broad competency not reducible to a skill | `minimal_qualifications` | hard/soft skills (if it *is* a skill) | | A nice-to-have broad competency | `preferable_qualifications` | same | | Employment conditions (contract, onsite/hybrid, shift, location, availability) | **Not scored** — keep it out of the config | anywhere scored | ### Languages are never skills This is the single most common routing mistake. A language requirement goes **only** in `skills.language_skills_config`, and the language names must be **plain** — no proficiency suffix: | ✗ Wrong | ✓ Correct | |---|---| | `"English (B2+)"` | `"English"` | | `"English - C1"` | `"English"` | | `"Spanish (native)"` | `"Spanish"` | A proficiency suffix is matched literally against the CV and almost never hits, so language scoring collapses to zero. The submit endpoint rejects language strings containing `()`, `[]`, or `/`. Capture the required level in your own `explanations` block instead. Two more language gotchas worth knowing: - The preferred-languages field is spelled **`prefered_languages`** — one `r`. It's a historical spelling the engine reads exactly; the natural-looking `preferred_languages` is silently ignored. - `target_countries` are **countries where the language is spoken** (proof of real-world use), not the job's location. For English you'd list `"United States"`, `"United Kingdom"`, etc. Leave it empty if the JD doesn't value it. ## Minimal vs preferable qualifications `minimal_qualifications` (essential) and `preferable_qualifications` (advantageous) are for **broad competencies that can't be reduced to a single hard/soft skill label** and that describe capability *depth*. They must be realistically inferable from a CV. **Good:** *"Demonstrated experience managing high-volume customer interactions with measurable performance outcomes."* — describes measurable capability depth. **Bad:** *"Ability to consistently deliver customer support over the phone while identifying opportunities to recommend products."* — that's a task description; express it as soft skills (Customer-Centric Communication, Sales Orientation) plus `relevant_roles`. Keep out of qualifications: - Employment conditions (contract length, onsite/hybrid, shift, schedule). - Willingness / availability statements ("willing to travel", "open to relocation"). - Time-based requirements — those belong in `workExperience.from` / `.to`. - Long JD task sentences rewritten as "Ability to…". If a requirement can reasonably be a hard or soft skill, put it there instead. Use qualifications sparingly — `0` is fine when skills cover the role. Whether qualifications are scored at all is controlled by the required `skills_config.requirements` toggle block (both `minimal_qualifications` and `preferable_qualifications` booleans must be present). ## Education ON vs OFF `education` is optional at submit — omit it and we auto-fill a safe, disabled default. But **if you send the block, send all of it**, and disable education by toggling, not by deleting: - To **disable** education scoring: send the full `education` block with `is_enabled: false`. The engine reads the block's structure either way; a partial or missing block when education is otherwise expected causes a failure downstream. - To **enable** it: set `is_enabled: true` and fill in `academic` / `non_academic`. Inside `academic`, `study_status` is **case-sensitive** and must be one of `Both`, `Undergraduate`, `Graduate`. A value like `"Graduated"` is rejected and, if it slipped through, would short-circuit academic scoring. Convention for weighting: when a degree isn't genuinely critical to the role (e.g. `degree_level: "High School"` with no specific field), keep `education.importance` and `academic.importance` at 1–2. Reserve 4–5 for roles where the degree truly matters. ## Cardinality limits The engine doesn't crash if you exceed these, but match scores degrade — over-long lists force every candidate to match an impossibly long specification, and scores collapse across the board. The submit endpoint enforces the caps, so over-long lists return `INVALID_INPUT`: | List | Max items | Why | |---|---|---| | `hard_skills` | 5 | Forces prioritising technical/tool requirements | | `soft_skills` | 5 | Forces prioritising behavioural requirements | | `hard_skills` + `soft_skills` combined | 10 | Beyond this the reasoning gets noisy | | `minimal_qualifications` | 3 | Only the few real non-skill competencies | | `preferable_qualifications` | 3 | Only the most meaningful nice-to-haves | | `relevant_industries` | 3 (and ≥ 1) | More dilutes industry-correlation scoring | | `industries_config` | 3 | Must mirror `relevant_industries` one-for-one | `relevant_industries` and `industries_config` must each contain **1–3 entries and mirror each other** — `industries_config` carries the per-industry weight, `relevant_industries` is the plain list the reasoning step reads. An empty `relevant_industries` is rejected. ## Minimal valid config This is the smallest config the submit endpoint accepts — `education` and `language_skills_config` are omitted and auto-filled: ```json { "name": "Senior Backend Engineer", "requirements": { "workExperience": { "from": 5, "to": 10, "importance": 5, "time_importance": 3, "transferable_skills": true, "minimum_job_experience_duration": 4, "threshold_for_one_exp_relevance": 84, "relevant_roles": ["Backend Engineer"], "relevant_companies": [], "relevant_industries": ["SaaS", "Fintech"], "industries_config": [ { "name": "SaaS", "importance": 5 }, { "name": "Fintech", "importance": 4 } ] }, "skills": { "importance": 5, "skills_config": { "hard_skills": [ { "name": "Node.js", "importance": 5 }, { "name": "TypeScript", "importance": 4 }, { "name": "PostgreSQL", "importance": 4 } ], "soft_skills": [ { "name": "Communication", "importance": 4 } ], "minimal_qualifications": [], "preferable_qualifications": [], "requirements": { "minimal_qualifications": true, "preferable_qualifications": true } } } } } ``` ## Pre-flight checklist Before submitting, verify: - `name` (or `position_name`) — non-empty string. - `workExperience.from ≤ workExperience.to` — integer years. - `workExperience.relevant_industries` — 1–3 items, non-empty. - `workExperience.industries_config` — same count, mirrors the above. - `skills.skills_config.requirements` — present, both booleans. - Every `importance` — integer 1–5 (no zeros, fractions, or out-of-range). - No language inside `hard_skills` / `soft_skills` / qualifications. - No proficiency suffix in language names. - Cardinality caps respected (industries ≤ 3, hard ≤ 5, soft ≤ 5, hard+soft ≤ 10, qualifications ≤ 3 each). - If you send `education`, send the full block (use `is_enabled: false` to disable, not omission). ## See also - [CV DeepMatch integration](/guides/cv-deepmatch) — submit, poll, and webhook walkthrough. - [`CvDeepMatchRequirements`](/cv-deepmatch/schemas/cvdeepmatchrequirements) — field-by-field schema reference. - [`CvDeepMatchSkillItem`](/cv-deepmatch/schemas/cvdeepmatchskillitem) — the shape of each skill / qualification entry. -------------------------------------------------------------------------------- ## cv-deepmatch Source: https://platform.zenhire.ai/docs/guides/cv-deepmatch/ --- id: cv-deepmatch title: CV DeepMatch integration sidebar_label: CV DeepMatch description: Step-by-step guide to submitting a CV, polling for the result, and verifying the webhook callback. --- # CV DeepMatch integration CV DeepMatch runs asynchronous CV-to-Job-Description matching. You upload a candidate's CV (PDF) plus a job description and a `requirements` configuration, and we return a score breakdown. You choose how to receive the result: - **Poll only** — omit `webhook_url` and poll `GET /api/v1/cvdeepmatch/{id}` until the run reaches a terminal state. This is the simplest integration and requires no public endpoint. - **Poll + webhook** — include a `webhook_url` and we *also* POST a signed callback to it when the match finishes. The poll endpoint still works; the webhook is an additional push channel, not a replacement. `webhook_url` is **optional**. When supplied it must be an HTTPS URL on a publicly-reachable host (see [Limitations](#limitations)). This guide walks you through your first match end-to-end. The full endpoint reference is the **CV DeepMatch API** section in the sidebar. ## Prerequisites - An API key — issued in the [ZenHire dashboard](https://platform.zenhire.ai). The key looks like `zh_api_…`. Send it on every request as the `X-API-Key` header. - The `cvdeepmatch` permission on your client. Contact support if your key returns `MISSING_PERMISSION`. - A CV in PDF format, **5 MB or smaller**. DOCX is **not** supported at launch (see [Limitations](#limitations) below). - **(Optional)** A publicly-reachable HTTPS endpoint, only if you want the webhook callback. Omit it to integrate poll-only. Localhost / private IPs are rejected when a URL is supplied. :::note Keys and **Projects** are managed in the ZenHire dashboard, not via the API — each match run inherits the Project of the key that submitted it. See [Authentication](/universal/authentication#getting-a-key) for details. ::: ## Step 1 — Submit a match `POST https://platform.zenhire.ai/api/v1/cvdeepmatch/submit` (multipart). ### curl ```bash curl -X POST https://platform.zenhire.ai/api/v1/cvdeepmatch/submit \ -H "X-API-Key: zh_api_…" \ -F "cv_file=@./candidate-cv.pdf;type=application/pdf" \ -F "position_id=eng-backend-2026" \ -F "job_description=Senior backend engineer with 5+ years of Node.js…" \ -F 'config={"name":"Senior Backend Engineer","requirements":{"workExperience":{"from":5,"to":10,"importance":5,"relevant_industries":["SaaS","Fintech"],"industries_config":[{"name":"SaaS","importance":5},{"name":"Fintech","importance":4}]},"skills":{"importance":5,"skills_config":{"hard_skills":[{"name":"Node.js","importance":5},{"name":"TypeScript","importance":4},{"name":"PostgreSQL","importance":4}],"soft_skills":[{"name":"Communication","importance":4}],"minimal_qualifications":[{"name":"5+ years backend","importance":5}],"preferable_qualifications":[],"requirements":{"minimal_qualifications":true,"preferable_qualifications":true}}}}}' \ -F "webhook_url=https://example.com/cvdeepmatch/callback" \ -F "idempotency_key=demo-2026-05-19-001" ``` The `webhook_url` line is **optional**. Drop it for a poll-only submit: ```bash curl -X POST https://platform.zenhire.ai/api/v1/cvdeepmatch/submit \ -H "X-API-Key: zh_api_…" \ -F "cv_file=@./candidate-cv.pdf;type=application/pdf" \ -F "position_id=eng-backend-2026" \ -F "job_description=Senior backend engineer with 5+ years of Node.js…" \ -F 'config={…}' \ -F "idempotency_key=demo-2026-05-19-001" # No webhook_url → poll GET /api/v1/cvdeepmatch/{id} for the result. ``` Successful response (HTTP 202): ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "processing", "message": "CV received. Matching in progress. Poll GET /api/v1/cvdeepmatch/{id} for status updates.", "estimated_time": "1-3 minutes" } ``` Save the `id` — it's the run's canonical [identifier](/universal/identifiers); you'll need it to poll or to correlate the webhook callback. ### Node.js (fetch + FormData) ```javascript const form = new FormData(); form.append( "cv_file", new Blob([fs.readFileSync("./candidate-cv.pdf")], { type: "application/pdf" }), "candidate-cv.pdf", ); form.append("position_id", "eng-backend-2026"); form.append("job_description", "Senior backend engineer with 5+ years of Node.js…"); form.append( "config", JSON.stringify({ name: "Senior Backend Engineer", requirements: { workExperience: { from: 5, to: 10, importance: 5, relevant_industries: ["SaaS", "Fintech"], industries_config: [ { name: "SaaS", importance: 5 }, { name: "Fintech", importance: 4 }, ], }, skills: { importance: 5, skills_config: { hard_skills: [ { name: "Node.js", importance: 5 }, { name: "TypeScript", importance: 4 }, { name: "PostgreSQL", importance: 4 }, ], soft_skills: [{ name: "Communication", importance: 4 }], minimal_qualifications: [{ name: "5+ years backend", importance: 5 }], preferable_qualifications: [], requirements: { minimal_qualifications: true, preferable_qualifications: true, }, }, }, }, }), ); form.append("webhook_url", "https://example.com/cvdeepmatch/callback"); form.append("idempotency_key", "demo-2026-05-19-001"); const res = await fetch("https://platform.zenhire.ai/api/v1/cvdeepmatch/submit", { method: "POST", headers: { "X-API-Key": process.env.ZENHIRE_API_KEY }, body: form, }); const json = await res.json(); console.log(json.id); ``` ### Python (requests) ```python import json import requests with open("./candidate-cv.pdf", "rb") as fh: res = requests.post( "https://platform.zenhire.ai/api/v1/cvdeepmatch/submit", headers={"X-API-Key": "zh_…"}, files={"cv_file": ("candidate-cv.pdf", fh, "application/pdf")}, data={ "position_id": "eng-backend-2026", "job_description": "Senior backend engineer with 5+ years of Node.js…", "config": json.dumps({ "name": "Senior Backend Engineer", "requirements": { "workExperience": { "from": 5, "to": 10, "importance": 5, "relevant_industries": ["SaaS", "Fintech"], "industries_config": [ {"name": "SaaS", "importance": 5}, {"name": "Fintech", "importance": 4}, ], }, "skills": { "importance": 5, "skills_config": { "hard_skills": [ {"name": "Node.js", "importance": 5}, {"name": "TypeScript", "importance": 4}, {"name": "PostgreSQL", "importance": 4}, ], "soft_skills": [{"name": "Communication", "importance": 4}], "minimal_qualifications": [ {"name": "5+ years backend", "importance": 5} ], "preferable_qualifications": [], "requirements": { "minimal_qualifications": True, "preferable_qualifications": True, }, }, }, }, }), "webhook_url": "https://example.com/cvdeepmatch/callback", "idempotency_key": "demo-2026-05-19-001", }, timeout=30, ) res.raise_for_status() print(res.json()["id"]) ``` ### `position_id` charset `position_id` is 1–28 characters, charset `[A-Za-z0-9._-]`. IDs that exceed 28 chars or include characters outside that set return `INVALID_INPUT` with `details.fields.position_id` set. Use a short, deterministic identifier (job-board ID, internal req-ID slug). ### Required `config` fields The `config` body field is a JSON object that mirrors the matching engine's contract. The following are required — submits missing any return `INVALID_INPUT`: - `name` — position title (or `position_name` alias). - `requirements.workExperience.from`, `.to` — integer years (must satisfy `from ≤ to`). - `requirements.workExperience.importance` — integer **1–5**. - `requirements.workExperience.relevant_industries` — array of 1–3 industry names. - `requirements.workExperience.industries_config` — one `{name, importance}` entry per `relevant_industries` entry. - `requirements.skills.importance` — integer **1–5**. - `requirements.skills.skills_config` — object containing skill arrays (see below). Cardinality + range rules: | Field | Limit | |---|---| | `hard_skills` | 0–5 entries | | `soft_skills` | 0–5 entries | | `hard_skills` + `soft_skills` combined | ≤ 10 entries | | `minimal_qualifications` | 0–3 entries | | `preferable_qualifications` | 0–3 entries | | Every `importance` value | integer 1–5 | | `study_status` | `Graduate` / `Undergraduate` / `Both` | `language_skills_config.required_languages` and `prefered_languages` must be plain language names — no proficiency suffix. Put proficiency into `explanations.language_skills_config.required_languages` (free-text). `"English"` ✓; `"English (B2+)"` ✗. Optional structures (`education`, `language_skills_config`, the optional sub-fields of `workExperience`) are auto-filled with safe defaults when omitted, so a minimal valid config only needs the fields above. These validation and cardinality rules are enforced server-side at submit — a config that violates them returns `INVALID_INPUT` rather than producing a silently-wrong score. The full field-by-field contract — the 1–5 importance scale, where each requirement belongs, education ON/OFF, minimal vs preferable qualifications, the language-config quirks (`prefered_languages` spelling, plain-name rule), and the cardinality limits — is documented in the [**Position config reference**](/guides/cv-deepmatch-position-config) guide, with the per-field schema at [`CvDeepMatchRequirements`](/cv-deepmatch/schemas/cvdeepmatchrequirements). The rules above are the integrator-facing summary; read the config guide when you need the complete picture. ### Idempotency Pass an `idempotency_key` to make the submit safe to retry. If the same key arrives within a 24 h window we return the **existing** `id` with HTTP 200 and `idempotent_replay: true`, instead of starting a new match. This is the recommended way to handle network failures and worker crashes. ## Step 2 — Wait for the result Two ways to get the final result: **poll** (always available), or **wait for the webhook** (only if you supplied a `webhook_url` on submit). Polling alone is sufficient — the webhook is an optional push channel on top of it. ### Option A — Poll (always available) `GET https://platform.zenhire.ai/api/v1/cvdeepmatch/{id}`. ```bash curl https://platform.zenhire.ai/api/v1/cvdeepmatch/550e8400-e29b-41d4-a716-446655440000 \ -H "X-API-Key: zh_api_…" ``` **Cadence:** minimum interval between polls for the same `id` is **10 seconds**. Faster polls return HTTP 429 with a `Retry-After` header. Once the row is in a terminal state (`finished` / `failed`) the rate limit is lifted — you can re-fetch freely. **Recommended backoff:** | Poll # | Wait before this poll | |--------|-----------------------| | 1 | 10 s after submit | | 2 | 15 s | | 3 | 30 s | | 4+ | 60 s | The `X-CVDM-Request-Id` header on the webhook correlates with your run's `id`. Typical end-to-end latency is 1–3 minutes. If a request is still `processing` after 10 minutes, contact support — something is wrong on our side. ### Option B — Webhook callback (optional) Only applies if you supplied a `webhook_url` on submit. We POST the final result to that URL. The callback fires once per successful match. Retries: up to 3 attempts (1 initial + 2 retries) with exponential backoff (1 s → 4 s → 16 s, with a small jitter). We retry on 5xx, 429, and network errors. 4xx responses **other than 429** are treated as a permanent contract failure and not retried. **Headers we send:** | Header | Value | |------------------------------|------------------------------------------------------------------| | `Content-Type` | `application/json` | | `X-CVDM-Request-Id` | `api_` — correlates with your run's `id`. | | `X-CVDM-Signature` | `t=,v1=` — Stripe-style HMAC-SHA256. | | `X-CVDM-Signature-Timestamp` | `` — same value as the `t=` field in the signature. | **Body shape (representative):** ```json { "external_request_id": "550e8400-e29b-41d4-a716-446655440000", "application_id": "app_abc123", "status": "finished", "cv2jd_result": { "overall_score": 0.81, "breakdown": { "workExperience": 0.85, "skills": 0.78, "education": 0.90 } } } ``` PII fields such as `cv_parser_result` (the candidate's full parsed CV) are stripped from the webhook payload — they're only available from the authenticated poll endpoint. ### Verifying the webhook signature The `X-CVDM-Signature` header has the form `t=,v1=`, where `` is the lower-case hex HMAC-SHA256 of the string `.` (no whitespace in the JSON). Recompute it with your **webhook signing secret** and verify every callback signature. > **Your signing secret:** generate (and later rotate) a signing secret > unique to your organization on the **Settings** page of your dashboard. The > secret (prefixed `whsec_`) is shown **once** at generation time — copy and > store it securely; afterwards only the last four characters are displayed. > Once generated, every callback to your `webhook_url` is signed with your > own secret. Until you generate one, callbacks are signed with a default > secret established with ZenHire when your integration was provisioned. > Rotating the secret takes effect immediately for subsequent callbacks. **To defend against replay attacks**, also reject any request whose `t=` value differs from the current time by more than **5 minutes**. #### Node.js verifier ```javascript const TOLERANCE_SECONDS = 300; // 5 minutes const header = req.headers["x-cvdm-signature"]; // 't=,v1=' if (!header) return false; const parts = Object.fromEntries( header.split(",").map((p) => p.split("=").map((s) => s.trim())), ); const ts = parts.t; const v1 = parts.v1; if (!ts || !v1) return false; const now = Math.floor(Date.now() / 1000); if (Math.abs(now - Number(ts)) > TOLERANCE_SECONDS) return false; // rawBody MUST be the exact bytes off the wire (capture before any // JSON.parse re-serialization). const signed = `${ts}.${rawBody}`; const expected = crypto .createHmac("sha256", secret) .update(signed, "utf8") .digest("hex"); return crypto.timingSafeEqual( Buffer.from(v1, "hex"), Buffer.from(expected, "hex"), ); } ``` #### Python verifier ```python import hmac import hashlib import time TOLERANCE_SECONDS = 300 # 5 minutes def verify_webhook(signature_header: str, raw_body: bytes, secret: str) -> bool: # 't=1747641600,v1=abcdef…' parts = dict(p.strip().split("=", 1) for p in signature_header.split(",")) ts = parts.get("t") v1 = parts.get("v1") if not ts or not v1: return False if abs(int(time.time()) - int(ts)) > TOLERANCE_SECONDS: return False signed = f"{ts}.{raw_body.decode('utf-8')}".encode("utf-8") expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(v1, expected) ``` Capture the **raw request body** before any framework deserializes it — re-serializing the parsed JSON can change byte order or whitespace and break the signature. ## Step 3 — Interpret the result The `cv2jd_result` block on the poll response (and inside the webhook payload) contains: | Field | Type | Meaning | |-----------------------------|---------|-------------------------------------------------------------------------| | `overall_score` | float | Aggregate match score, 0–1. | | `breakdown.workExperience` | float | How well the candidate's experience matches the `workExperience` block. | | `breakdown.skills` | float | How well the candidate's skills match the `skills_config` list. | | `breakdown.education` | float | Present when `requirements.education` was supplied. | Use `overall_score` for ranking; use the `breakdown` to surface a "why this score" explanation to recruiters. ## Limitations - **PDF only at launch.** DOCX is on the roadmap (AWS Issue #2) but is rejected with `INVALID_INPUT` today. Hand-converting DOCX to PDF on your end (e.g., LibreOffice headless, Pandoc) works. - **5 MB max per CV.** Larger files return `INVALID_INPUT`. - **HTTPS-only webhooks.** Plain HTTP returns `INSECURE_WEBHOOK_URL`. - **Public hosts only.** `webhook_url` that resolves to a private, loopback, or link-local address (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16`, etc.) is rejected with `PRIVATE_WEBHOOK_URL`. The DNS resolution is **re-checked** before each delivery attempt to defend against DNS rebinding. - **Minimum poll interval is 10 seconds** per `id` while a match is `processing`. Faster polls return 429. ## Error codes Every error response uses the shared [standard error envelope](/universal/errors): ```json { "error": { "code": "INVALID_INPUT", "message": "One or more fields failed validation.", "timestamp": "2026-05-19T10:00:00.000Z", "requestId": "req_1705412345678_abc123", "details": { "fields": { "position_id": ["…"] } } } } ``` | `error.code` | HTTP | Meaning | Retry? | |--------------------------|------|----------------------------------------------------------------------|--------| | `INVALID_INPUT` | 400 | A field failed validation. See `error.details.fields`. | After fix | | `INVALID_FILE_CONTENT` | 400 | `cv_file` did not start with a valid PDF magic header. | After fix | | `INSECURE_WEBHOOK_URL` | 400 | `webhook_url` is `http://`. Use HTTPS. | After fix | | `PRIVATE_WEBHOOK_URL` | 400 | `webhook_url` resolves to a private / loopback address. | After fix | | `MISSING_PERMISSION` | 401 / 403 | Missing API key, invalid API key, or missing `cvdeepmatch` perm. | Contact support | | `INSUFFICIENT_CREDITS` | 402 | Balance below the per-match minimum. `error.details` has the gap. | Top up | | `REQUEST_NOT_FOUND` | 404 | No request with this `id` for your client. | No | | `RATE_LIMITED` | 429 | Poll interval too short. Honour `Retry-After`. | After backoff | | `STEP_FUNCTIONS_FAILED` | 502 | Downstream pipeline failed to start. | Yes, with same `idempotency_key` | | `INTERNAL_ERROR` | 500 | Uncategorised server error. | After delay | ## Next steps - Read the [Position config reference](/guides/cv-deepmatch-position-config) for how to shape the `requirements` config correctly (importance scale, field routing, education, qualifications, cardinality limits). - Browse the full [CV DeepMatch API reference](/cv-deepmatch/zenhire-cv-deepmatch-api) for every parameter, schema, and example. - See the [API authentication guide](/universal/authentication) for key rotation and IP allow-listing. - For interactive exploration, use the **Try it** console on each endpoint reference page. -------------------------------------------------------------------------------- ## cv-deepsearch-ingestion Source: https://platform.zenhire.ai/docs/guides/cv-deepsearch-ingestion/ --- id: cv-deepsearch-ingestion title: CV DeepSearch — Ingestion sidebar_label: Ingestion description: Fill a corpus — batch-ingest parsed CVs and track each candidate's embedding status. --- # CV DeepSearch — Ingestion (fill the corpus) Ingestion is the **first of the two CV DeepSearch flows**: you fill a corpus with parsed CVs once, then [search](/guides/cv-deepsearch-search) it repeatedly. This guide covers ingesting candidates and reading their embedding status. For the concepts (corpus, candidate, embedding status), see the [CV DeepSearch overview](/cv-deepsearch/overview). The full endpoint reference is the **Ingestion** group in the CV DeepSearch API reference sidebar. ## Prerequisites - An API key — issued in the [ZenHire dashboard](https://platform.zenhire.ai). It looks like `zh_api_…`. Send it on every request as the `X-API-Key` header. - The `cvdeepsearch` permission on your client. Contact support if your key returns `MISSING_PERMISSION`. - Parsed CVs as JSON (you bring your own CV parser, or reuse the one from your ATS). CV DeepSearch ingests the parsed JSON — it does not parse PDFs. ## Ingest a corpus `POST https://platform.zenhire.ai/api/v1/cvds/candidates` Batch your parsed CVs into a corpus. Each candidate is keyed by your own `external_id` and embedded for semantic retrieval. ```bash curl -X POST "https://platform.zenhire.ai/api/v1/cvds/candidates" \ -H "X-API-Key: zh_api_…" \ -H "Content-Type: application/json" \ -d '{ "corpus_id": "acme-eng-pool", "candidates": [ { "external_id": "cand-001", "parsed_cv": { "name": "Jane Doe", "skills": ["Node.js", "PostgreSQL"], "experience": [] }, "tags": ["batch-2026-q2"] }, { "external_id": "cand-002", "parsed_cv": { "name": "John Roe", "skills": ["Python", "AWS"], "experience": [] } } ] }' ``` Response (HTTP 200): ```json { "results": [ { "external_id": "cand-001", "status": "accepted", "embedding_status": "embedded" }, { "external_id": "cand-002", "status": "accepted", "embedding_status": "embedded" } ], "summary": { "accepted": 2, "updated": 0, "unchanged": 0, "failed": 0 } } ``` ### Node.js ```javascript const res = await fetch("https://platform.zenhire.ai/api/v1/cvds/candidates", { method: "POST", headers: { "X-API-Key": process.env.ZENHIRE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ corpus_id: "acme-eng-pool", candidates: [ { external_id: "cand-001", parsed_cv: { name: "Jane Doe", skills: ["Node.js"] }, tags: ["batch-2026-q2"] }, ], }), }); const json = await res.json(); console.log(json.summary); ``` ### Python ```python import requests res = requests.post( "https://platform.zenhire.ai/api/v1/cvds/candidates", headers={"X-API-Key": "zh_…", "Content-Type": "application/json"}, json={ "corpus_id": "acme-eng-pool", "candidates": [ {"external_id": "cand-001", "parsed_cv": {"name": "Jane Doe", "skills": ["Node.js"]}}, ], }, timeout=60, ) res.raise_for_status() print(res.json()["summary"]) ``` ### Request shape — single or array `candidates` is always an **array**. Send one candidate as a one-element array, or many at once: ```json // one candidate { "corpus_id": "acme-eng-pool", "candidates": [ { "external_id": "cand-001", "parsed_cv": { "name": "Jane Doe" } } ] } // many candidates { "corpus_id": "acme-eng-pool", "candidates": [ { "external_id": "cand-001", "parsed_cv": { "name": "Jane Doe" } }, { "external_id": "cand-002", "parsed_cv": { "name": "John Roe" } } ] } ``` ### Things to know - **`corpus_id` is fail-closed.** Set it top-level or per candidate. A candidate with no resolvable `corpus_id` rejects the whole batch with `MISSING_CORPUS_ID`. - **Idempotent.** Re-posting an unchanged candidate is a no-op (`unchanged`); a changed CV re-embeds it (`updated`). Re-sync a corpus by re-posting. - **Limits: up to 500 candidates and ~10 MB per request.** More than 500 candidates returns `BATCH_TOO_LARGE`; a body over ~10 MB returns `PAYLOAD_TOO_LARGE`. For larger corpora, split into multiple requests (≤ 500 candidates and ≤ ~10 MB each) or use bulk sync. - **`parsed_cv` is PII.** It's embedded but never returned by the read endpoints — you keep your own copy. - **Embedding can be async.** Large batches return `pending` and embed in the background. Poll the candidate-status endpoint (below) until the status is `embedded` before relying on the candidate appearing in a search. ## Check embedding status A candidate is only returned by a search once its `embedding_status` is `embedded`. There are two read endpoints. ### List a corpus `GET https://platform.zenhire.ai/api/v1/cvds/candidates?corpus_id=acme-eng-pool` ```bash curl "https://platform.zenhire.ai/api/v1/cvds/candidates?corpus_id=acme-eng-pool&limit=50" \ -H "X-API-Key: zh_api_…" ``` Returns a page of candidate statuses (paginated via `cursor` / `next_cursor`). You can filter to one state with `embedding_status=pending|embedded|failed`. `corpus_id` is **mandatory** — a request without it is rejected with `MISSING_CORPUS_ID` (fail-closed). ### Get one candidate `GET https://platform.zenhire.ai/api/v1/cvds/candidates/{external_id}?corpus_id=acme-eng-pool` ```bash curl "https://platform.zenhire.ai/api/v1/cvds/candidates/cand-001?corpus_id=acme-eng-pool" \ -H "X-API-Key: zh_api_…" ``` Poll this until `embedding_status` is `embedded` before searching. ## Error codes The ingestion endpoints use the shared [standard error envelope](/universal/errors): | `error.code` | HTTP | Meaning | Retry? | |---|---|---|---| | `INVALID_INPUT` | 400 | A field failed validation. | After fix | | `MISSING_CORPUS_ID` | 400 | A mandatory `corpus_id` was missing (fail-closed). | After fix | | `BATCH_TOO_LARGE` | 400 | More than 500 candidates in one ingest. | After fix | | `PAYLOAD_TOO_LARGE` | 413 | Request body over the ~10 MB per-request limit. | Split / smaller batch | | `MISSING_PERMISSION` | 401 / 403 | Missing/invalid key, or missing `cvdeepsearch` perm. | Contact support | | `NOT_FOUND` | 404 | No candidate with that id for your client. | No | | `INTERNAL_ERROR` | 500 | Uncategorised server error. | After delay | ## Next step Once your corpus is embedded, move on to the [**Search guide**](/guides/cv-deepsearch-search) to query it for a position. -------------------------------------------------------------------------------- ## cv-deepsearch-search Source: https://platform.zenhire.ai/docs/guides/cv-deepsearch-search/ --- id: cv-deepsearch-search title: CV DeepSearch — Search sidebar_label: Search description: Query a filled corpus — trigger a search for a position and consume the best-first results. --- # CV DeepSearch — Search (query the corpus) Search is the **second of the two CV DeepSearch flows**: once you've [filled a corpus](/guides/cv-deepsearch-ingestion), you run repeated searches against it for different positions. This guide covers triggering a search and consuming the run. For the concepts, see the [CV DeepSearch overview](/cv-deepsearch/overview). The full endpoint reference is the **Search** group in the CV DeepSearch API reference sidebar. ## Prerequisites - A corpus with at least one `embedded` candidate — see the [Ingestion guide](/guides/cv-deepsearch-ingestion). - An API key with the `cvdeepsearch` permission. - **(Optional)** A publicly-reachable HTTPS endpoint, only if you want the completion webhook. ## Run a search `POST https://platform.zenhire.ai/api/v1/cvds/search` ```bash curl -X POST "https://platform.zenhire.ai/api/v1/cvds/search" \ -H "X-API-Key: zh_api_…" \ -H "Content-Type: application/json" \ -d '{ "corpus_id": "acme-eng-pool", "position_id": "eng-backend-2026", "position_metadata": { "name": "Senior Backend Engineer", "requirements": { "workExperience": { "from": 5, "to": 10, "importance": 5, "relevant_industries": ["SaaS"], "industries_config": [{ "name": "SaaS", "importance": 5 }] }, "skills": { "importance": 5, "skills_config": { "hard_skills": [{ "name": "Node.js", "importance": 5 }], "requirements": { "minimal_qualifications": true, "preferable_qualifications": true } } } } }, "top_n": 50, "webhook_url": "https://example.com/cvds/callback" }' ``` Response (HTTP 200): ```json { "id": "cvds_8mK2pQ7rT1aB9xY3wZ0vNn", "status": "pending" } ``` Save the `id` — you poll it (or correlate the webhook) to get the result. ### The `position_metadata` config `position_metadata` is the **same shape CV DeepMatch consumes** as its `requirements` config. The API requires only that it be a **non-empty object**; the inner shape is the search engine's contract. The full field-by-field contract — the 1–5 importance scale, where each requirement belongs, education ON/OFF, minimal vs preferable qualifications, the language-config quirks, and the cardinality limits — is in the [**Position config reference**](/guides/cv-deepmatch-position-config), with the per-field schema at [`CvDeepMatchRequirements`](/cv-deepmatch/schemas/cvdeepmatchrequirements). ### `job_updated` & plan caching The search planner caches a plan per `position_id`. Set `job_updated: true` only when the role's requirements changed since the last search (it rebuilds the plan); leave it `false` (default) to reuse the cached plan and re-rank. ### `top_n` `top_n` caps how many ranked candidates you want back. The server enforces an upper bound; values above the cap are clamped. ### Idempotency Pass an `idempotency_key` to make the trigger safe to retry — the same key replays the existing run `id`. ## Consume the results Two ways: **poll** (always available), or **wait for the webhook** (only if you supplied a `webhook_url`). ### Option A — Poll `GET https://platform.zenhire.ai/api/v1/cvds/runs/{id}` ```bash curl "https://platform.zenhire.ai/api/v1/cvds/runs/cvds_8mK2pQ7rT1aB9xY3wZ0vNn" \ -H "X-API-Key: zh_api_…" ``` ```json { "id": "cvds_8mK2pQ7rT1aB9xY3wZ0vNn", "status": "pending", "corpus_id": "acme-eng-pool", "position_id": "eng-backend-2026", "is_job_updated": false, "top_n": 50, "source": "requests", "created_at": "2026-06-09T10:00:00.000Z", "results": null, "results_pending": true } ``` When results land, the candidate list is returned **best-first** — an ordered ranking, not a percentage match. Treat each result's `score` as a sort key, not a 0–100% score. :::note Results side is in progress The best-first candidate list, the `GET /api/v1/cvds/runs/{id}/results` pagination endpoint, and the final webhook completion payload are finalized in a follow-up release. Until then the run poll returns the run's status with `results: null` and `results_pending: true`. The shapes below describe the **provisional** completion envelope; treat them as a forward reference, not a final contract. ::: ### Option B — Webhook callback (optional) If you supplied a `webhook_url`, we POST a signed callback when the run finishes. The signing + retry mechanism is **identical to CV DeepMatch**: an HMAC-SHA256 signature in the `X-CVDM-Signature` header (`t=,v1=`, Stripe-style), up to 3 attempts with exponential backoff, retried on 5xx / 429 / network errors. You can register a webhook endpoint and **send yourself a test `cvdeepsearch` completion event** today from the self-serve webhook tool in your dashboard, even before the live completion payload is finalized — pick the `cvdeepsearch.completed` event type and hit **Test**. **Provisional completion payload shape** (subject to change when results land — modeled on the CV DeepMatch completion callback): ```json { "event": "cvdeepsearch.completed", "id": "cvds_8mK2pQ7rT1aB9xY3wZ0vNn", "corpus_id": "acme-eng-pool", "position_id": "eng-backend-2026", "status": "completed", "completed_at": "2026-06-09T10:02:00.000Z" } ``` **Verifying the signature** — use the same verifier as CV DeepMatch (the `X-CVDM-Signature` header has the form `t=,v1=`, where `` is the lower-case hex HMAC-SHA256 of `.`). See the [CV DeepMatch verification snippet](/guides/cv-deepmatch#verifying-the-webhook-signature) for the Node.js and Python implementations. ## Error codes The search endpoints use the shared [standard error envelope](/universal/errors): | `error.code` | HTTP | Meaning | Retry? | |---|---|---|---| | `INVALID_INPUT` | 400 | A field failed validation. | After fix | | `MISSING_CORPUS_ID` | 400 | A mandatory `corpus_id` was missing (fail-closed). | After fix | | `INSECURE_WEBHOOK_URL` | 400 | `webhook_url` is `http://`. Use HTTPS. | After fix | | `PRIVATE_WEBHOOK_URL` | 400 | `webhook_url` resolves to a private / loopback address. | After fix | | `MISSING_PERMISSION` | 401 / 403 | Missing/invalid key, or missing `cvdeepsearch` perm. | Contact support | | `NOT_FOUND` | 404 | No run with that id for your client. | No | | `STEP_FUNCTIONS_FAILED` | 502 | Downstream search pipeline failed to start. | Yes, same `idempotency_key` | | `INTERNAL_ERROR` | 500 | Uncategorised server error. | After delay | ## Next steps - Read the [Position config reference](/guides/cv-deepmatch-position-config) for how to shape `position_metadata`. - Browse the full [CV DeepSearch API reference](/cv-deepsearch/overview) for every parameter, schema, and example. - For interactive exploration, use the **Try it** console on each endpoint reference page, or the **API Sandbox** in your dashboard. -------------------------------------------------------------------------------- ## Error handling Source: https://platform.zenhire.ai/docs/guides/error-handling/ > How to handle the common error cases and recover cleanly. # Error handling Most production errors fall into a small number of patterns. This page covers the right response to each. ## Standard error shape Every 4xx and 5xx response from the public API uses the platform's shared [standard error envelope](/universal/errors): ```json { "error": { "code": "INVALID_LANGUAGE_CODE", "message": "Invalid language code. Must be a 2-letter ISO 639-1 code.", "timestamp": "2026-04-21T12:34:56.789Z", "requestId": "req_1705412345678_abc123", "details": { /* optional, code-specific extras */ } } } ``` `code`, `message`, and `timestamp` are always present. `requestId` and `details` are included when they apply — `requestId` is emitted once the route handler has generated one (middleware-level rejections happen before that and omit it). It's the same envelope on every module; see [Identifiers](/universal/identifiers) for how `requestId` differs from the run `id` and your `externalId`. **Rate-limit and service-warming responses** also include `error.retryAfterSeconds` and the standard HTTP `Retry-After` header. Respect the header. ## Common cases ### 400 Bad Request Malformed input. Fix the request and retry. Typical codes: - `MISSING_AUDIO_FILE` - `UNSUPPORTED_FORMAT` - `FILE_TOO_LARGE` — audio exceeds the per-request file-size limit. - `INVALID_LANGUAGE_CODE` - `INVALID_EXTERNAL_ID` - `INVALID_METADATA` - `AUDIO_TOO_SHORT` / `AUDIO_TOO_LONG` Do not retry a `400`. It will fail the same way. ### 401 Unauthorized Check your API key. Possible codes: - `MISSING_API_KEY` — no `X-API-Key` or `Authorization` header. - `INVALID_API_KEY` — key not recognized or marked inactive. - `API_KEY_EXPIRED` — key had an expiration date that's now past. Rotate. - `CLIENT_INACTIVE` — the account behind the key has been disabled. Contact support. ### 402 Payment Required `INSUFFICIENT_CREDITS` — balance below minimum. Top up before retrying. ### 403 Forbidden - `MULTILINGUAL_NOT_ENABLED` — request language is non-English and the permission isn't enabled on your account. - `IP_NOT_ALLOWED` — the API key has an IP whitelist and your request didn't originate from an allowed address. ### 429 Too Many Requests Two flavors — treat them differently: - **`RATE_LIMIT_EXCEEDED`** on submit: you're submitting too fast. Back off with exponential delay. - **`POLL_RATE_LIMITED`** on poll: you're polling a single `requestId` too fast. Respect `Retry-After` and continue polling the same `requestId`. ### 503 Service Unavailable - `SERVICE_WARMING_UP` — the scoring service is warming up after a fresh deployment (typically 15–20 minutes after a deploy). Retry with the suggested `retryAfterSeconds`, then back off further if still warming. ### `status: "failed"` on a poll response The run reached a terminal failure. **No credits were charged.** Common codes: - `TRANSCRIPTION_FAILED` — audio quality issue or provider error. - `TRANSCRIPTION_LOW_CONFIDENCE` — audio content doesn't match the requested language. - `SCORING_FAILED` — scoring service error after retry. - `SERVICE_CONFIGURATION_ERROR` — external provider misconfigured (rare; contact support). - `STUCK_TIMEOUT` — run exceeded its watchdog timeout. - `INTERNAL_ERROR` — unexpected error (contact support). Retry policy: resubmit the audio with a fresh request. Do not reuse the same `requestId` — it's terminal. ## Retry strategy at a glance | Response | Retry? | How | |---|---|---| | `400` | No | Fix input. | | `401` | No | Check key / rotate if expired / contact support if account inactive. | | `402` | No | Top up balance. | | `403` | No | Contact support (language permission, IP whitelist). | | `429 RATE_LIMIT_EXCEEDED` | Yes | Respect `Retry-After`, then exponential backoff. | | `429 POLL_RATE_LIMITED` | Yes | Respect `Retry-After`, continue polling same `requestId`. | | `503 SERVICE_WARMING_UP` | Yes | Retry after 30–60s, back off further if repeated. | | `status: "failed"` | Case-by-case | Fresh submission with new audio if applicable. | ## Full error code catalog See [Resources → Error codes](/resources/errors) for every code the API can return, with recommended actions. ## See in the API reference Every endpoint page lists the full response schema for each status code: - **[POST /api/v1/speech/analyze](/api/submit-speech-analysis)** — 400 / 401 / 402 / 403 / 429 / 503 - **[GET /api/v1/speech/analyze/\{id\}](/api/poll-speech-analysis)** — 200 (including `status: failed`), 404, 429 - **[GET /api/v1/speech/runs](/api/list-runs)** — 400 / 401 - **[GET /api/v1/credits](/api/get-credits)** — 401 -------------------------------------------------------------------------------- ## multilingual Source: https://platform.zenhire.ai/docs/guides/multilingual/ --- id: multilingual title: Multilingual analysis sidebar_position: 3 description: Non-English language support and how to enable it on your account. --- # Multilingual analysis The API supports **16 languages**. English uses a dedicated scoring pipeline tuned on English audio. Other languages run through a separate multilingual pipeline. ## Supported languages | Code | Language | |---|---| | `en` | English *(default)* | | `es` | Spanish | | `fr` | French | | `de` | German | | `pt` | Portuguese | | `it` | Italian | | `nl` | Dutch | | `hi` | Hindi | | `zh` | Chinese | | `ja` | Japanese | | `ko` | Korean | | `ar` | Arabic | | `tr` | Turkish | | `pl` | Polish | | `ru` | Russian | | `sr` | Serbian | ## Requesting a non-English analysis Pass the ISO 639-1 code in the `language` form field: ```bash curl -X POST "https://platform.zenhire.ai/api/v1/speech/analyze" \ -H "X-API-Key: zh_api_…" \ -F "audio=@entrevista.mp3" \ -F "language=es" ``` ## Enabling multilingual on your account Non-English analysis is gated behind a permission. If your account doesn't have it enabled, you'll get: ``` 403 MULTILINGUAL_NOT_ENABLED ``` Contact your ZenHire account manager to enable it. English (`en`) is always available without the flag. ## Quality differences The English pipeline has seen the most tuning and benchmark validation. The multilingual pipeline is optimized for broad language coverage, so its behavior isn't identical to the English pipeline. For cross-language comparisons (e.g., ranking candidates across different languages against one another), build in a review step — don't assume scores are directly comparable across language codes. ## See in the API reference - **[POST /api/v1/speech/analyze](/api/submit-speech-analysis)** — see the `language` form field and `MULTILINGUAL_NOT_ENABLED` error - **[GET /api/v1/speech/analyze/\{id\}](/api/poll-speech-analysis)** — see the `audio.requestedLanguage` field in the response -------------------------------------------------------------------------------- ## Rate limits & concurrency Source: https://platform.zenhire.ai/docs/guides/rate-limits/ > Per-minute rate limits, concurrent request caps, and how queueing works. # Rate limits & concurrency Two independent throttling mechanisms apply to every request: 1. **Per-minute rate limit** (how fast you submit). 2. **Concurrency cap** (how many runs can process *simultaneously*). ## Rate limits (per minute) Enforced on the submit endpoint: | | Limit | |---|---| | Default | 500 requests/minute per client | The rate limit is configured per key and visible in the dashboard. Over the limit returns: ``` 429 RATE_LIMIT_EXCEEDED ``` Back off and retry. Implement exponential backoff — don't hot-loop. Need higher throughput? **Contact ZenHire support** — enterprise plans can raise the per-minute cap. ## Concurrency cap Default **8 simultaneous `processing` runs per client** (contact support to extend). Enforced atomically at submit time. When you hit the cap, the submit endpoint still returns `202 Accepted` — but with `status: "queued"`: ```json { "id": "req_...", "status": "queued", "queuePosition": 3, "activeRequests": 8, "pollIntervalSeconds": 20 } ``` Queued requests start automatically in FIFO order when a slot frees up. **You don't need to retry submit.** Just poll the returned `requestId`. ## Poll-endpoint rate limit The poll endpoint has its own minimum interval: **10 seconds per `requestId`** for non-terminal statuses. Polling faster returns `429 POLL_RATE_LIMITED` with a `Retry-After` header. ## Strategy recommendations - **Respect `pollIntervalSeconds`** from every response. - **Implement exponential backoff** on `429 RATE_LIMIT_EXCEEDED`. - **Don't treat `status: queued` as an error** — it's a normal submit outcome. - **Parallelize freely** — queued runs cost nothing until they start processing. ## See in the API reference - **[POST /api/v1/speech/analyze](/api/submit-speech-analysis)** — 429 `RATE_LIMIT_EXCEEDED` response, `queuePosition` / `activeRequests` fields on 202 queued response - **[GET /api/v1/speech/analyze/\{id\}](/api/poll-speech-analysis)** — 429 `POLL_RATE_LIMITED` response and `Retry-After` header -------------------------------------------------------------------------------- ## curl Source: https://platform.zenhire.ai/docs/code-examples/curl/ --- id: curl title: cURL sidebar_position: 1 description: Copy-paste cURL commands for every ZenHire endpoint. --- # cURL recipes All examples use the placeholder `YOUR_API_KEY` — replace with your own `zh_api_…` key. ## Submit speech analysis ```bash curl -X POST "https://platform.zenhire.ai/api/v1/speech/analyze" \ -H "X-API-Key: YOUR_API_KEY" \ -F "audio=@interview.mp3" \ -F "language=en" \ -F "externalId=candidate-abc-123" ``` ## Poll for results ```bash curl "https://platform.zenhire.ai/api/v1/speech/analyze/req_..." \ -H "X-API-Key: YOUR_API_KEY" ``` ## List runs ```bash # All runs (newest first, default limit 20) curl "https://platform.zenhire.ai/api/v1/speech/runs" \ -H "X-API-Key: YOUR_API_KEY" # All runs for a specific external id curl "https://platform.zenhire.ai/api/v1/speech/runs?externalId=candidate-abc-123" \ -H "X-API-Key: YOUR_API_KEY" # Last 24 hours of successful runs curl "https://platform.zenhire.ai/api/v1/speech/runs?status=success&createdAfter=2026-04-19T00:00:00Z" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Credits ```bash curl "https://platform.zenhire.ai/api/v1/credits" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Health ```bash curl "https://platform.zenhire.ai/api/v1/health" ``` ## End-to-end submit + poll loop (bash) ```bash #!/bin/bash set -euo pipefail API_KEY="${ZENHIRE_API_KEY:?set ZENHIRE_API_KEY}" AUDIO="interview.mp3" BASE="https://platform.zenhire.ai" SUBMIT=$(curl -sS -X POST "$BASE/api/v1/speech/analyze" \ -H "X-API-Key: $API_KEY" \ -F "audio=@$AUDIO" \ -F "language=en") REQUEST_ID=$(echo "$SUBMIT" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') echo "Submitted: $REQUEST_ID" while true; do sleep 15 RESP=$(curl -sS "$BASE/api/v1/speech/analyze/$REQUEST_ID" \ -H "X-API-Key: $API_KEY") STATUS=$(echo "$RESP" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p') case "$STATUS" in success|partial) echo "Done: $STATUS" echo "$RESP" | python3 -m json.tool break ;; failed) echo "Failed: $RESP" exit 1 ;; *) echo "Still $STATUS..." ;; esac done ``` -------------------------------------------------------------------------------- ## Node.js Source: https://platform.zenhire.ai/docs/code-examples/node/ > Node.js recipes for ZenHire Speech Analysis (native fetch, no dependencies). # Node.js recipes All examples use native `fetch` (Node 18+). No npm dependencies required. ## End-to-end submit + poll ```javascript const API_BASE = "https://platform.zenhire.ai"; const API_KEY = process.env.ZENHIRE_API_KEY; async function analyze(audioPath, externalId) { // 1. Submit const form = new FormData(); form.append("audio", new Blob([fs.readFileSync(audioPath)]), audioPath); form.append("language", "en"); if (externalId) form.append("externalId", externalId); const submitRes = await fetch(`${API_BASE}/api/v1/speech/analyze`, { method: "POST", headers: { "X-API-Key": API_KEY }, body: form, }); if (!submitRes.ok) throw new Error(`Submit failed: ${submitRes.status}`); const { id: requestId } = await submitRes.json(); console.log(`Submitted: ${requestId}`); // 2. Poll const start = Date.now(); let interval = 15_000; while (Date.now() - start < 20 * 60_000) { await new Promise((r) => setTimeout(r, interval)); const res = await fetch( `${API_BASE}/api/v1/speech/analyze/${requestId}`, { headers: { "X-API-Key": API_KEY } } ); if (res.status === 429) { const retryAfter = parseInt(res.headers.get("Retry-After") || "15", 10); await new Promise((r) => setTimeout(r, retryAfter * 1000)); continue; } if (!res.ok) throw new Error(`Poll failed: ${res.status}`); const data = await res.json(); if (data.status === "success" || data.status === "partial") return data; if (data.status === "failed") { throw new Error(`${data.error?.code}: ${data.error?.message}`); } // Escalate: 15s → 30s → 60s const elapsed = Date.now() - start; interval = elapsed < 120_000 ? 15_000 : elapsed < 300_000 ? 30_000 : 60_000; } throw new Error("Polling budget exhausted"); } const result = await analyze("interview.mp3", "candidate-abc-123"); console.log(`Overall: ${result.scores.overall} (${result.scores.cefrLevel})`); ``` ## List runs ```javascript const res = await fetch( "https://platform.zenhire.ai/api/v1/speech/runs?limit=50&status=success", { headers: { "X-API-Key": process.env.ZENHIRE_API_KEY } } ); const { runs, pagination } = await res.json(); console.log(`${runs.length} successful runs, hasMore=${pagination.hasMore}`); ``` ## Check credits ```javascript const res = await fetch("https://platform.zenhire.ai/api/v1/credits", { headers: { "X-API-Key": process.env.ZENHIRE_API_KEY }, }); const { creditBalance } = await res.json(); console.log(`Balance: ${creditBalance} credits`); ``` -------------------------------------------------------------------------------- ## Python Source: https://platform.zenhire.ai/docs/code-examples/python/ > Python recipes for ZenHire Speech Analysis. # Python recipes Install dependency: ```bash pip install requests ``` ## End-to-end submit + poll See the full production-ready example on **[Your first request](/get-started/first-request)**. ## Batch analysis from a folder ```python from pathlib import Path from concurrent.futures import ThreadPoolExecutor import os, time, requests API_BASE = "https://platform.zenhire.ai" API_KEY = os.environ["ZENHIRE_API_KEY"] def submit(audio_path: Path) -> str: with audio_path.open("rb") as f: r = requests.post( f"{API_BASE}/api/v1/speech/analyze", headers={"X-API-Key": API_KEY}, files={"audio": f}, data={"language": "en", "externalId": audio_path.stem}, timeout=60, ) r.raise_for_status() return r.json()["id"] def poll(request_id: str) -> dict: while True: time.sleep(15) r = requests.get( f"{API_BASE}/api/v1/speech/analyze/{request_id}", headers={"X-API-Key": API_KEY}, timeout=30, ) if r.status_code == 429: time.sleep(int(r.headers.get("Retry-After", "15"))) continue r.raise_for_status() d = r.json() if d["status"] in ("success", "partial", "failed"): return d def analyze(audio_path: Path) -> dict: return poll(submit(audio_path)) audio_files = list(Path("recordings/").glob("*.mp3")) with ThreadPoolExecutor(max_workers=8) as pool: for path, result in zip(audio_files, pool.map(analyze, audio_files)): print(f"{path.name}: {result['status']} " f"overall={result.get('scores',{}).get('overall')}") ``` ## Look up runs by externalId ```python import requests, os API_KEY = os.environ["ZENHIRE_API_KEY"] def runs_for_candidate(external_id: str) -> list[dict]: r = requests.get( "https://platform.zenhire.ai/api/v1/speech/runs", headers={"X-API-Key": API_KEY}, params={"externalId": external_id, "limit": 100}, ) r.raise_for_status() return r.json()["runs"] ``` ## Check credits before submitting a big batch ```python import requests, os API_KEY = os.environ["ZENHIRE_API_KEY"] def credits() -> int: r = requests.get( "https://platform.zenhire.ai/api/v1/credits", headers={"X-API-Key": API_KEY}, ) r.raise_for_status() return r.json()["creditBalance"] if credits() < 100: raise SystemExit("Top up before running this batch") ``` -------------------------------------------------------------------------------- ## Changelog Source: https://platform.zenhire.ai/docs/resources/changelog/ > Notable API changes. # Changelog ## Unreleased ### Added - **CV DeepSearch API — corpus search (ingest + search + run status).** A new module at `/api/v1/cvds/*` for finding the best-matching candidates in a corpus of parsed CVs against a position — the inverse of CV DeepMatch. Ingest parsed CVs into a corpus (`POST /api/v1/cvds/candidates`, idempotent batch upsert, fail-closed `corpus_id`, **up to 500 candidates and ~10 MB per request** — `BATCH_TOO_LARGE` / `PAYLOAD_TOO_LARGE` above those), check embedding status (`GET /api/v1/cvds/candidates[/{external_id}]`), then trigger a search (`POST /api/v1/cvds/search` → run `id`) and poll the run (`GET /api/v1/cvds/runs/{id}`). The `position_metadata` config is the same shape CV DeepMatch consumes as its `requirements` config. Auth is the unified `clients.permissions[]` model — calls require the **`cvdeepsearch` permission**. A `cvdeepsearch` completion webhook event type is selectable + test-sendable in the self-serve webhook tool. **Results side in progress:** the best-first result list, the `/runs/{id}/results` pagination endpoint, and the final completion webhook payload land in a follow-up release; until then the run poll returns `results: null` with `results_pending: true`. See the [CV DeepSearch overview](/cv-deepsearch/overview) and the two flow guides — [Ingestion](/guides/cv-deepsearch-ingestion) (fill the corpus) and [Search](/guides/cv-deepsearch-search) (query the corpus). - **CV DeepMatch API — general availability.** The `/api/v1/cvdeepmatch/*` endpoint family — submit (`POST /api/v1/cvdeepmatch/submit`), poll (`GET /api/v1/cvdeepmatch/{id}`), and list (`GET /api/v1/cvdeepmatch/requests`) — is now generally available. Upload a CV (PDF) plus a job description and a `requirements` configuration; receive an `id` and either poll the status endpoint or wait for a signed webhook callback. Auth is the unified `clients.permissions[]` model — call requires the `cvdeepmatch` permission. HMAC-SHA256 webhook signatures use the Stripe-style `t=,v1=` header format in `X-CVDM-Signature`. **PDF only at launch** — DOCX support and additional matching algorithms are on the roadmap. See the [CV DeepMatch integration guide](/guides/cv-deepmatch) and the [CV DeepMatch API reference](/cv-deepmatch/zenhire-cv-deepmatch-api). - **Per-run metadata + tags (Speech and CV DeepMatch).** Both `POST /api/v1/speech/analyze` and `POST /api/v1/cvdeepmatch/submit` now accept an optional `metadata` object (a `{key:value}` map — string keys to string/number/boolean values, ≤ 50 keys, key ≤ 40 chars, value ≤ 500 chars, total ≤ 8 KB) and `tags` array (≤ 20 distinct strings, each 1–40 chars). Use them to correlate a run back to your own records (a candidate ID, an external customer ID, a batch label) without keeping a side table. Both are echoed back on every poll/detail response (`{}` / `[]` when none) and are filterable. Submitting them is fully backward compatible — omit them and runs behave exactly as before. - **Interview API — general availability.** The `/api/v1/interview/*` endpoint family is now reachable with an `X-API-Key` (the platform's third module, alongside Speech and CV DeepMatch). Create a reusable interviewer **persona** (`POST /api/v1/interview/personas`), start a per-candidate **session** (`POST /api/v1/interview/personas/{personaId}/sessions`) — which returns a candidate link carrying its token in the URL **fragment** (`#t=`, never sent to a server) — then poll the session (`GET /api/v1/interview/sessions/{sessionId}`) and fetch the **recording** (streamed `audio/wav`) and **transcript** (JSON) once it completes. Eight endpoints in total: create/list personas, get persona, start session, list/get sessions, recording, transcript. Auth is the unified `clients.permissions[]` model — calls require the **`interview` permission** (else `403 MISSING_PERMISSION`); a key is pinned to its own client and its sessions inherit the key's project. Sessions accept optional `metadata` + `tags` for correlation, identical to Speech and CV DeepMatch. See the [Interview API overview](/interview/overview) and the [Interview API quickstart](/interview/quickstart). *(This module was previewed as "Voice"; it was renamed to "Interview" — endpoints `/api/v1/interview/*`, the `interview` permission, and `X-Interview-*` signature headers — before this release.)* - **Per-organization webhook signing secret.** Generate (and later rotate) a signing secret unique to your organization on the dashboard **Settings** page. The `whsec_`-prefixed secret is shown once at generation; afterwards only its last four characters are displayed. Once generated, every CV DeepMatch and Interview webhook callback is signed with your own secret (same `t=,v1=` HMAC-SHA256 format in `X-CVDM-Signature` / `X-Interview-Signature`). Until you generate one, callbacks continue to use a default secret. See [Verifying the webhook signature](/guides/cv-deepmatch#verifying-the-webhook-signature). - **Re-invite a candidate after a closed-without-result interview.** When an interview ends **without a result** — for example one auto-closed at the maximum duration (`status: "failed"`, `error_code: "SESSION_TIMEOUT"`) that captured no recording or transcript — you can re-invite the candidate from the **ZenHire console**. Re-inviting resets the session to `initiated`, clears its failure reason, and mints a fresh candidate link while preserving the session's persona, candidate reference, project, tags, and metadata. A session that already has a result (`completed`, or `failed` with a recording/transcript) cannot be re-invited. See [Re-inviting a candidate](/interview/sessions#re-inviting-a-candidate-after-a-failed-interview). - **Interview maximum-duration auto-close is now documented + surfaced.** A live interview that runs past the maximum duration without a completion signal is automatically closed (`status: "failed"`, `error_code: "SESSION_TIMEOUT"`), freeing its concurrent-interview slot. The candidate UI now shows the duration budget up front. A candidate whose interview was auto-closed can be [re-invited from the console](/interview/sessions#re-inviting-a-candidate-after-a-failed-interview). See [Maximum interview duration](/interview/sessions#maximum-interview-duration). - `GET /api/v1/speech/runs` — list your API runs with optional filters (`project` incl. the `none` no-project sentinel, `status`, `tags` contains-all, `metadata_key`/`metadata_value` exact-match, `serviceType`, `externalId`, `createdAfter`, `createdBefore`) and pagination. The `project`/`tags`/`metadata` filters use the same language as the CV DeepMatch and Interview run lists — learn the filter syntax once and it works across every module. - `externalId` optional field on `POST /api/v1/speech/analyze`. Non-unique customer-supplied correlation id, echoed on every poll response, filterable on `/speech/runs`. - `GET /api/v1/cvdeepmatch/requests` now accepts the same `project` (incl. the `none` no-project sentinel), `tags` (contains-all), and `metadata_key`/`metadata_value` (exact-match) filters as `GET /api/v1/speech/runs` and `GET /api/v1/interview/sessions`, in addition to the existing `status`/`limit`/`cursor`. The filter language is now identical across all three module lists — learn it once and it works everywhere. Fully backward compatible: omit the new params and the list behaves exactly as before. - `explainability` object on successful/partial poll responses: `{ vocab, fluency }` — a short plain-language explanation per dimension, safe to show directly to end users. Optional — omitted on `failed` runs and when a per-dimension feature breakdown isn't available. - Public documentation site at `platform.zenhire.ai/docs` with `llms.txt` / `llms-full.txt` bundles. ### Changed - **API key format updated to `zh_api_…`.** Newly issued keys start with `zh_api_` instead of the previous `zh_` prefix. All keys issued before this change — including legacy `zh_speech_live_…`, `zh_speech_test_…`, `zh_cv_live_…`, and `zh_…` keys — continue to work without any action needed on your side. No migration required. - **Speech run terminal status is now precise: `success`, `partial`, or `failed`.** A run that completed but had an *optional* step degrade (for example the impressive-vocabulary boost couldn't run, or audio trimming fell back to the full clip) now returns `status: "partial"` — it still carries a full result payload with scores, analysis, and transcript, exactly like `success`. A run only returns `failed` when a *required* step failed (no result, no charge). Previously every completed run returned the same status regardless of whether an optional step degraded, so `partial` was documented but never actually emitted. No field shapes changed; if you branch only on `failed` vs. not-`failed`, no action is needed. See [terminal failures](/resources/errors) and the [poll endpoint](/speech/overview). - **Speech runs now fail honestly when accent can't be measured.** Previously, if the accent measurement failed on a `POST /api/v1/speech/analyze` run, the run still completed using a placeholder accent value — which could mis-grade the result. Now such a run terminates with `status: "failed"` and `error.code: "ACCENT_SCORE_UNAVAILABLE"` (no credits charged) instead of returning a score we didn't actually measure. Resubmit the run if you hit it. See [terminal failures](/resources/errors). - **Interview concurrency cap now applies to live interviews, not link creation.** Creating an interview session (`POST /api/v1/interview/personas/{personaId}/sessions`) no longer counts against your `maxConcurrentRequests` cap and no longer returns `429 CONCURRENCY_LIMIT` — minting a candidate link is unbounded (each link is still bounded by its `expires_in` token TTL). The cap now counts **active** (live) interviews and is enforced when a candidate actually starts their interview. You can therefore queue up as many candidate links as you like; only the number of simultaneously-running interviews is capped. Leaked sessions (a candidate joins but never finishes) are automatically closed after a maximum duration so they stop holding a slot. - **Docs restructured around the 3-module + Universal model.** The guides now have one section per module — **[Speech](/speech/overview)**, **[Interview](/interview/overview)**, **[CV DeepMatch](/cv-deepmatch/overview)** — plus a **[Universal](/universal/overview)** section that documents the cross-module concepts **once**: [authentication](/universal/authentication), the run [`id` model](/universal/identifiers) (the public `id` vs. your `externalId` correlation tag vs. the error-envelope `requestId`), the [standard error envelope](/universal/errors), [credits](/universal/credits), and [health](/universal/health). Endpoint contracts are unchanged; this reorganizes how the docs are presented so onboarding to any module is consistent. - **API reference + sandbox grouped by module.** The API reference and the in-dashboard API Sandbox are now organized into four sections — **CV DeepMatch**, **Interview**, **Speech**, and **Universal** — so the cross-module endpoints (`GET /api/v1/credits`, `GET /api/v1/health`) sit in their own **Universal** section instead of being filed under one module. Endpoint contracts are unchanged; this is purely how the surface is presented. - **Unified run identifier — every run/session response now carries a single `id`.** Across all three modules (Speech, CV DeepMatch, Interview) the run/session response now returns ONE canonical identifier field, `id`, holding the public id you use to fetch it (`GET /api/v1//{id}`). The previous module-specific names are gone: CV DeepMatch no longer returns `request_id`/`external_request_id`, Speech returns `id` instead of `requestId`, and a created Interview session returns `id` instead of `session_id`. The customer-supplied correlation tag (`externalId` on Speech, `candidate_ref` on Interview) stays a separate, clearly-named field — it is never folded into `id`. There is no compatibility alias for the old field names. - **CV DeepMatch `source` parameter retired.** The `source` form/query parameter (`production` | `testing`) on `POST /api/v1/cvdeepmatch/submit` is no longer part of the contract. Any value you send — including the previous `?source=testing` — is now ignored (not an error) and the run is recorded as ordinary API traffic. No action needed on your side; existing integrations that still send it keep working. - **CV DeepMatch `webhook_url` is now optional.** You can integrate poll-only — omit `webhook_url` and poll `GET /api/v1/cvdeepmatch/{id}` until a terminal state. Supplying a `webhook_url` (HTTPS + public host) additionally enables the signed callback; it is no longer required to receive results. - **CV DeepMatch position-config validation enforced server-side.** Submit now validates the `config` body before starting a match — every `importance` is an integer 1–5, `workExperience.from ≤ to`, `relevant_industries` holds 1–3 entries 1:1 with `industries_config`, `hard_skills`/`soft_skills` ≤ 5 each and ≤ 10 combined, `minimal_qualifications`/`preferable_qualifications` ≤ 3 each, `study_status ∈ {Graduate, Undergraduate, Both}`, and languages are plain names (no proficiency suffix). Omitted optional structures are auto-filled with safe defaults. Configs that violate a rule return `INVALID_INPUT` with `error.details.fields` rather than producing a silently-wrong score. - **CV DeepMatch `position_id` charset tightened to `[A-Za-z0-9._-]`, 1–28 characters.** Longer IDs or characters outside that set now return `INVALID_INPUT` with `details.fields.position_id`. - Terminal-state naming clarified: `success` | `partial` | `failed`. There is no `completed` status. (The prior customer-facing TXT referenced a non-existent `completed` status — that's been corrected.) - Audio formats supported: MP3, WAV, M4A, WebM, OGG, FLAC (prior docs incorrectly said "MP3 only"). - Max audio duration: 45 minutes (prior docs incorrectly said 20 minutes). - **Speech credit admission is now exact-cost, not a flat minimum.** `POST /api/v1/speech/analyze` is admitted when the request's cost (1 credit per started minute of audio, minimum 1) is **available** — where available = your credit balance minus credits currently held for other in-flight requests. The previous flat "≥ 15 credits to submit anything" floor is gone: a small balance can now fund a short clip (e.g. 3 credits funds a 2-minute request). Credits are **held** at submit and only **charged** when the analysis succeeds; a failed run is never charged and its hold is freed automatically. `402 INSUFFICIENT_CREDITS` now means "cost exceeds available" and includes `details.cost` + `details.available`. If the audio duration can't be read at submit, the request is rejected synchronously with `400 AUDIO_DURATION_UNAVAILABLE` (previously surfaced later as a failed run). - Concurrency default: 8 simultaneous `processing` runs per client (prior docs incorrectly said 30). - Health endpoint path: `GET /api/v1/health` (prior docs incorrectly said `/api/health`). ### Removed - `public/api-documentation.txt` (the old hand-maintained flat TXT). Requests to that URL now redirect to `/llms-full.txt`. -------------------------------------------------------------------------------- ## Error codes Source: https://platform.zenhire.ai/docs/resources/errors/ > Every error code the ZenHire API can return, with recommended actions. # Error codes Every code below appears as `error.code` in the standard error envelope: ```json { "error": { "code": "", "message": "…", "timestamp": "2026-04-21T12:34:56.789Z", "requestId": "req_…" } } ``` `code`, `message`, and `timestamp` are always present. `requestId` is included once the route handler has generated one (middleware-level rejections like `INVALID_API_KEY` omit it). Rate-limit and service-warming responses add `retryAfterSeconds` alongside the standard `Retry-After` HTTP header. ## 400 — Validation | Code | When | Action | |---|---|---| | `MISSING_AUDIO_FILE` | No `audio` field in the multipart body. | Add the file. | | `UNSUPPORTED_FORMAT` | File isn't a valid MP3/WAV/M4A/WebM/OGG/FLAC. | Convert or fix the file. | | `AUDIO_TOO_SHORT` | Audio under 3 minutes. | Submit longer audio. | | `AUDIO_TOO_LONG` | Audio over 45 minutes. | Trim the audio. | | `AUDIO_DURATION_UNAVAILABLE` | The audio duration could not be read (file corrupted or unsupported). | Re-encode the file and resubmit. | | `FILE_TOO_LARGE` | File exceeds 25 MB. | Compress or trim. | | `INVALID_LANGUAGE_CODE` | Not a 2-letter ISO 639-1 code. | Use `en`, `es`, `fr`, etc. | | `INVALID_EXTERNAL_ID` | externalId empty or > 255 chars. | Provide 1–255 chars, or omit. | | `INVALID_METADATA` | metadata field is not valid JSON. | Fix the JSON. | | `INVALID_STATUS` | `status` query param on `/runs` not in allow-list. | Use a valid lifecycle value. | | `INVALID_SERVICE_TYPE` | `serviceType` query param on `/runs` not in allow-list. | Use `speech` or `cv_deepmatch`. | | `INVALID_DATE` | `createdAfter`/`createdBefore` not valid ISO 8601. | Use a proper datetime. | | `INVALID_PARAMETERS` | Some cutSetting out of bounds. | Check parameter ranges. | ## 401 — Authentication | Code | Action | |---|---| | `MISSING_API_KEY` | No `X-API-Key` / `Authorization` header. Add it. | | `INVALID_API_KEY` | Key unrecognized or marked inactive. Check your env var. | | `API_KEY_EXPIRED` | Key's expiry has passed. Rotate to a fresh key. | | `CLIENT_INACTIVE` | The account behind this key is disabled. Contact support. | ## 402 — Credits | Code | Action | |---|---| | `INSUFFICIENT_CREDITS` | Top up before retrying. Check `GET /api/v1/credits`. | ## 403 — Permissions | Code | Action | |---|---| | `MULTILINGUAL_NOT_ENABLED` | Contact your account manager to enable non-English analysis. | | `IP_NOT_ALLOWED` | The API key has an IP whitelist and your request didn't originate from an allowed address. | ## 404 — Not found | Code | Action | |---|---| | `REQUEST_NOT_FOUND` | Wrong `requestId`, or it belongs to a different client. | ## 429 — Rate limiting | Code | Where | Action | |---|---|---| | `RATE_LIMIT_EXCEEDED` | Submit endpoint. | Exponential backoff. | | `POLL_RATE_LIMITED` | Poll endpoint, same `requestId` polled faster than every 10s. | Respect `Retry-After`. | ## 503 — Service unavailable | Code | Action | |---|---| | `SERVICE_WARMING_UP` | Scoring service still warming up (15–20 min after a new deploy). Retry with backoff. | ## Terminal failures on a run (`status: "failed"`) Returned inside a `200 OK` poll response — the `error.code` reveals which pipeline step failed. **No credits are charged on `failed` runs.** | Code | Meaning | |---|---| | `TRANSCRIPTION_FAILED` | Audio quality issue or transcription provider error. | | `TRANSCRIPTION_LOW_CONFIDENCE` | Audio content doesn't match the requested language. | | `SCORING_FAILED` | Scoring service error after retry. | | `VOCAB_MODEL_TIMEOUT` | Vocabulary model timed out. | | `FLUENCY_MODEL_TIMEOUT` | Fluency model timed out. | | `ACCENT_SCORE_UNAVAILABLE` | Accent could not be measured. The run fails rather than returning a score we didn't measure — resubmit. | | `SERVICE_CONFIGURATION_ERROR` | External provider misconfigured. Contact support. | | `STUCK_TIMEOUT` | Run exceeded its watchdog timeout. | | `INTERNAL_ERROR` | Unexpected error. Contact support. | -------------------------------------------------------------------------------- ## For LLMs Source: https://platform.zenhire.ai/docs/resources/llms/ > Plain-text bundles of the full docs, designed for one-shot LLM ingestion. # :robot: For LLMs This site publishes two plain-text files following the [llms.txt convention](https://llmstxt.org/) so AI coding assistants and LLMs can ingest the full API documentation without a browser: ## `/llms-full.txt` — the full docs as a single file Every page of this documentation, concatenated in reading order, with section separators. Paste it into Claude, GPT, Copilot, or any LLM as context and ask questions like: - *"Write me a Python function that submits an interview and polls until success, handling 429 on the poll endpoint correctly."* - *"What happens to my credits if a run returns `status: partial`?"* - *"Generate a TypeScript type for the poll response."* → **[Download llms-full.txt](pathname:///docs/llms-full.txt)** ## `/llms.txt` — short index The table of contents in `llms.txt` format: one line per page, with the title and URL. Useful when you want an LLM to decide which specific page to fetch. → **[Download llms.txt](pathname:///docs/llms.txt)** ## How often is it updated? Both files are **regenerated on every docs build**, which runs on every deployment of the ZenHire platform. They are always in sync with the live API — including the exact response shapes, error codes, and parameter defaults. ## Can I embed this into my AI agent? Yes. Both files are plain text, publicly served, and stable at their URLs: ``` https://platform.zenhire.ai/llms.txt https://platform.zenhire.ai/llms-full.txt ``` Fetch, cache, and pass as system context. No authentication required. ## Example: Claude API with ZenHire docs as context ```python import anthropic, requests docs = requests.get("https://platform.zenhire.ai/llms-full.txt").text client = anthropic.Anthropic() message = client.messages.create( model="claude-opus-4-7", max_tokens=1024, system=f"""You are a ZenHire API integration assistant. Use the following documentation as authoritative context: {docs}""", messages=[{ "role": "user", "content": "Show me a production-grade Node.js submit+poll loop that handles all documented error cases.", }], ) print(message.content[0].text) ``` -------------------------------------------------------------------------------- ## Get credit balance Source: https://platform.zenhire.ai/docs/api/get-credits.api/ > Returns current credit balance, aggregate usage, and recent transactions. Returns current credit balance, aggregate usage, and recent transactions. -------------------------------------------------------------------------------- ## Health check Source: https://platform.zenhire.ai/docs/api/get-health.api/ > Checks API and downstream-service status. **No authentication required.** Checks API and downstream-service status. **No authentication required.** Returns `200` when all checks report `healthy` or `configured`; `503` when any check reports `unhealthy`, `unreachable`, or `not_configured`. -------------------------------------------------------------------------------- ## List runs Source: https://platform.zenhire.ai/docs/api/list-runs.api/ > Lists your API runs, newest first. Returns a compact summary per run — Lists your API runs, newest first. Returns a compact summary per run — fetch the full result (analysis, transcript, etc.) via `GET /api/v1/speech/analyze/{id}`. Playground runs are excluded — they are tracked separately and are not part of the API runs collection. Request -------------------------------------------------------------------------------- ## Poll analysis status Source: https://platform.zenhire.ai/docs/api/poll-speech-analysis.api/ > Returns the current status of a previously submitted analysis. Returns the current status of a previously submitted analysis. Safe to poll repeatedly. The minimum enforced interval is 10 seconds for non-terminal statuses; faster polls return `429`. Response shape varies by `status`: - `queued` / `processing`: lightweight status payload, `Retry-After` header set - `success` / `partial`: full result payload with scores, analysis, transcript - `failed`: terminal with `error.code` and `error.message` The `id` does not expire — you can stop and resume polling later. (The path parameter accepts the same value returned as `id` on submit.) Request -------------------------------------------------------------------------------- ## ErrorResponse Source: https://platform.zenhire.ai/docs/api/schemas/errorresponse.schema/ -------------------------------------------------------------------------------- ## HealthResponse Source: https://platform.zenhire.ai/docs/api/schemas/healthresponse.schema/ -------------------------------------------------------------------------------- ## PollResponse Source: https://platform.zenhire.ai/docs/api/schemas/pollresponse.schema/ > Shape varies by `status`. See the endpoint examples. Shape varies by `status`. See the endpoint examples. -------------------------------------------------------------------------------- ## RunSummary Source: https://platform.zenhire.ai/docs/api/schemas/runsummary.schema/ -------------------------------------------------------------------------------- ## Scores Source: https://platform.zenhire.ai/docs/api/schemas/scores.schema/ > Flat score object. Scores are on a 0–5 scale; percentages are 0–100. Flat score object. Scores are on a 0–5 scale; percentages are 0–100. -------------------------------------------------------------------------------- ## SubmitResponse Source: https://platform.zenhire.ai/docs/api/schemas/submitresponse.schema/ -------------------------------------------------------------------------------- ## Speech — Analysis Source: https://platform.zenhire.ai/docs/api/speech-analysis.tag/ > Speech — Analysis Submit and retrieve speech proficiency analyses. ```mdx-code-block ``` -------------------------------------------------------------------------------- ## Speech — Runs Source: https://platform.zenhire.ai/docs/api/speech-runs.tag/ > Speech — Runs Query historical Speech runs. ```mdx-code-block ``` -------------------------------------------------------------------------------- ## Submit speech analysis Source: https://platform.zenhire.ai/docs/api/submit-speech-analysis.api/ > Submits an audio file for asynchronous speech proficiency analysis. Submits an audio file for asynchronous speech proficiency analysis. Returns immediately (202 Accepted) with an `id`. Poll `GET /api/v1/speech/analyze/{id}` for results. ### Audio requirements - **Accepted formats**: MP3, WAV, M4A, WebM, OGG, FLAC - **Minimum duration**: 3 minutes - **Maximum duration**: 45 minutes - **Maximum file size**: 25 MB ### Rate limits - Default: 500 requests/minute per client (configurable per key) ### Concurrency Default **8 simultaneous `processing` runs per client** (contact support to extend). When at capacity, the request is **queued** (not rejected) and starts automatically when a slot frees up, in FIFO order. Request -------------------------------------------------------------------------------- ## Universal — Credits Source: https://platform.zenhire.ai/docs/api/universal-credits.tag/ > Universal — Credits Check credit balance and usage (shared across all modules). ```mdx-code-block ``` -------------------------------------------------------------------------------- ## Universal — Health Source: https://platform.zenhire.ai/docs/api/universal-health.tag/ > Universal — Health Service health status (shared across all modules). ```mdx-code-block ``` -------------------------------------------------------------------------------- ## ZenHire API Source: https://platform.zenhire.ai/docs/api/introduction/ > This reference covers the ZenHire platform APIs, grouped into modules. This reference covers the ZenHire platform APIs, grouped into modules. All endpoints authenticate with an `X-API-Key` header. **CV DeepMatch** (`platform.zenhire.ai/api/v1/cvdeepmatch`) — asynchronous CV ↔ job-description match scoring. Submit a CV (PDF) plus a job description and a requirements config; poll for a structured score breakdown. **Interview** (`platform.zenhire.ai/api/v1/interview`) — AI voice interviews. Create a reusable interviewer persona, start a per-candidate session that mints a candidate link, then retrieve the recording (WAV) and transcript (JSON) in-platform once the interview ends. **CV DeepSearch** (`platform.zenhire.ai/api/v1/cvds`) — search a corpus of parsed CVs for the best-matching candidates against a position. Ingest a corpus once, then run repeated searches; poll the run or receive a webhook for the best-first results. **Speech** (`platform.zenhire.ai/api/v1/speech`) — asynchronous speech-proficiency analysis. Upload an audio recording of an interview and receive vocabulary, fluency, and accent scores aligned with the CEFR framework. Submit returns an `id`; poll for results. **Universal** (`platform.zenhire.ai/api/v1`) — cross-module endpoints shared by every module: credit balance (`/api/v1/credits`) and health (`/api/v1/health`). Pick a module from the sidebar to see its endpoints and schemas.
API key issued in the ZenHire dashboard. Format: `zh_api_…`.
Security Scheme Type: apiKey
Header parameter name: X-API-Key

Contact

Book a demo: URL: [https://zenhire.ai/request-demo?source=api-docs](https://zenhire.ai/request-demo?source=api-docs)
-------------------------------------------------------------------------------- ## Lifecycle Source: https://platform.zenhire.ai/docs/cv-deepmatch/lifecycle.tag/ > Lifecycle Poll status and list previous CV DeepMatch requests. ```mdx-code-block ``` -------------------------------------------------------------------------------- ## List CV DeepMatch requests Source: https://platform.zenhire.ai/docs/cv-deepmatch/list-cvdeepmatch.api/ > Paginated list of the caller's own CV DeepMatch requests, newest Paginated list of the caller's own CV DeepMatch requests, newest first. Cursor-based: pass the previous response's `next_cursor` back as `?cursor=` to fetch the next page. Request -------------------------------------------------------------------------------- ## CV DeepMatch overview Source: https://platform.zenhire.ai/docs/cv-deepmatch/overview/ > Asynchronous CV-to-job-description match scoring on one unified /api/v1/cvdeepmatch surface — submit, poll, list, and optional signed webhooks. # CV DeepMatch overview CV DeepMatch runs **asynchronous CV ↔ job-description matching**. You upload a candidate's CV (PDF) plus a job description and a `requirements` config, and ZenHire returns a structured score breakdown. **Base URL:** `https://platform.zenhire.ai/api/v1/cvdeepmatch` ## One unified surface Every CV DeepMatch operation lives under a **single** `/api/v1/cvdeepmatch/*` namespace: | Method | Path | What it does | |---|---|---| | `POST` | `/api/v1/cvdeepmatch/submit` | Submit a CV + JD — returns `id`. | | `GET` | `/api/v1/cvdeepmatch/{id}` | Poll a single match by its `id`. | | `GET` | `/api/v1/cvdeepmatch/requests` | List your matches, newest first. | Credits and health are [Universal](/universal/overview) endpoints (`/api/v1/credits`, `/api/v1/health`), shared with the other modules. ## How you get the result You choose how to receive the match: - **Poll only** — omit `webhook_url` and poll `GET /api/v1/cvdeepmatch/{id}` until the run is terminal. No public endpoint required. - **Poll + webhook** — supply a `webhook_url` and ZenHire *also* POSTs a signed callback when the match finishes. The poll endpoint still works; the webhook is an additional push channel, not a replacement. ## Identifiers, errors, credits CV DeepMatch follows the shared [Universal](/universal/overview) model: - The match [`id`](/universal/identifiers) — poll, fetch, and correlate with it. - Your `externalId` correlation tag — your own reference, echoed back. - The [standard error envelope](/universal/errors) on every 4xx/5xx (`{ error: { code, message, timestamp, requestId, details? } }`). - One shared [credit ledger](/universal/credits) — 3 credits per match. ## Get started - **[CV DeepMatch integration guide](/guides/cv-deepmatch)** — submit, poll, and verify the webhook signature end-to-end. - **[Position config reference](/guides/cv-deepmatch-position-config)** — how to shape the `requirements` config (importance scale, cardinality, education). - **[CV DeepMatch API reference](/cv-deepmatch/zenhire-cv-deepmatch-api)** — every endpoint, parameter, and schema with a "Try it" console. -------------------------------------------------------------------------------- ## Poll a CV DeepMatch request Source: https://platform.zenhire.ai/docs/cv-deepmatch/poll-cvdeepmatch.api/ > Returns the current state of a request. Minimum poll interval is Returns the current state of a request. Minimum poll interval is 10 seconds while the request is in `processing` — faster polls return `429` with a `Retry-After` header. Terminal rows (`finished`, `failed`) can be re-fetched freely. Cross-tenant requests return `404` to avoid leaking the existence of other clients' request ids. Request -------------------------------------------------------------------------------- ## CvDeepMatchError Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatcherror.schema/ > Standard error envelope returned by every CV DeepMatch endpoint Standard error envelope returned by every CV DeepMatch endpoint on a non-2xx response. -------------------------------------------------------------------------------- ## CvDeepMatchRequest Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatchrequest.schema/ > Full state of a single CV DeepMatch request, as returned by Full state of a single CV DeepMatch request, as returned by `GET /api/v1/cvdeepmatch/{id}`. -------------------------------------------------------------------------------- ## CvDeepMatchRequestSummary Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatchrequestsummary.schema/ > Compact row returned by `GET /api/v1/cvdeepmatch/requests`. Full Compact row returned by `GET /api/v1/cvdeepmatch/requests`. Full result fields are only available on the per-request poll endpoint. -------------------------------------------------------------------------------- ## CvDeepMatchRequirements Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatchrequirements.schema/ > The matching engine's position-config contract — the `requirements` The matching engine's position-config contract — the `requirements` object inside the submitted `config`. Mirrors the **Position Metadata Specification** that the engine consumes. `workExperience` and `skills` are **required**; `education` is optional at submit (auto-filled with safe defaults when omitted), but if you send it you must send the *full* block — see the **[Position config reference](/guides/cv-deepmatch-position-config)** guide for the routing rules, the 1–5 importance scale, and the cardinality limits. **Every `importance` field anywhere in this schema is an integer 1–5.** The engine divides it by 5 to derive a weight; `0`, negative, fractional, or `>5` values do not error at this layer but produce silently-wrong match scores. -------------------------------------------------------------------------------- ## CvDeepMatchResult Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatchresult.schema/ > The matching result, populated when `status === 'finished'`. The matching result, populated when `status === "finished"`. `breakdown` is keyed by the same dimensions present in the submitted `requirements` block. -------------------------------------------------------------------------------- ## CvDeepMatchSkillItem Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatchskillitem.schema/ > A single weighted requirement entry, used inside every A single weighted requirement entry, used inside every `skills_config` list (`hard_skills`, `soft_skills`, `minimal_qualifications`, `preferable_qualifications`). One label plus how much it matters to the role. -------------------------------------------------------------------------------- ## CvDeepMatchSubmitRequest Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatchsubmitrequest.schema/ > `multipart/form-data` body for `POST /api/v1/cvdeepmatch/submit`. `multipart/form-data` body for `POST /api/v1/cvdeepmatch/submit`. -------------------------------------------------------------------------------- ## CvDeepMatchSubmitResponse Source: https://platform.zenhire.ai/docs/cv-deepmatch/schemas/cvdeepmatchsubmitresponse.schema/ -------------------------------------------------------------------------------- ## Submission Source: https://platform.zenhire.ai/docs/cv-deepmatch/submission.tag/ > Submission Submit a new CV DeepMatch request. See the [Position config reference](/guides/cv-deepmatch-position-config) guide for a full, field-by-field explanation of the requirements config. ```mdx-code-block ``` -------------------------------------------------------------------------------- ## Submit CV + job description for asynchronous matching Source: https://platform.zenhire.ai/docs/cv-deepmatch/submit-cvdeepmatch.api/ > Submits a CV (PDF) plus a job description and a `requirements` Submits a CV (PDF) plus a job description and a `requirements` configuration. Returns `202 Accepted` with an `id`. Poll `GET /api/v1/cvdeepmatch/{id}` for the current status, or wait for the webhook callback to fire. ### File requirements - **Format:** PDF only at launch (`application/pdf` MIME + `.pdf` extension). DOCX returns `INVALID_INPUT`. - **Magic-byte check:** the uploaded bytes must start with a valid PDF header (`%PDF-`). Files that pass the extension/MIME gate but fail the magic-byte check return `INVALID_FILE_CONTENT`. - **Max size:** 5 MB. ### Webhook URL requirements - Must use `https://` (plain HTTP returns `INSECURE_WEBHOOK_URL`). - Must NOT resolve to a private, loopback, or link-local address (returns `PRIVATE_WEBHOOK_URL`). The DNS resolution is re-checked immediately before each delivery attempt to defend against DNS-rebinding. ### Idempotency Pass an `idempotency_key` to make the submit safe to retry. If the same key arrives within 24 h the existing `id` is returned with HTTP `200` and `idempotent_replay: true` instead of a fresh `202`. ### Required permission Client's `permissions[]` must contain `cvdeepmatch`. Without it the request returns `403 MISSING_PERMISSION`. ### Credits Each successful match deducts credits when the run completes. At submission time the available balance is checked — submits from clients with insufficient credits are rejected with `402 INSUFFICIENT_CREDITS`. Request -------------------------------------------------------------------------------- ## ZenHire CV DeepMatch API Source: https://platform.zenhire.ai/docs/cv-deepmatch/zenhire-cv-deepmatch-api.info/ > Asynchronous CV-to-Job-Description matching. Submit a candidate's CV Asynchronous CV-to-Job-Description matching. Submit a candidate's CV (PDF) along with a job description and a `requirements` configuration, receive an `id`, then either poll the status endpoint or wait for the webhook callback to deliver the final match result. All endpoints require an `X-API-Key` header. The calling client's `permissions[]` must contain `cvdeepmatch` — contact support to enable. ### File support at launch - **PDF only.** DOCX support is on the roadmap (tracked as AWS Issue #2). DOCX uploads are rejected with `INVALID_INPUT`. - **5 MB maximum** per CV. ### Webhook callback Match results are delivered to your `webhook_url` as a `POST` with HMAC-SHA256 signature in the `X-CVDM-Signature` header (Stripe-style `t=,v1=`). See the [CV DeepMatch integration guide](/guides/cv-deepmatch) for the verification snippet. ### Polling Minimum interval between polls for the same `id` is **10 seconds**. Faster polls return `429` with a `Retry-After` header.
API key issued in the ZenHire dashboard. Format: `zh_live_…` or `zh_test_…`.
Security Scheme Type: apiKey
Header parameter name: X-API-Key

Contact

Book a demo: URL: [https://zenhire.ai/request-demo?source=api-docs](https://zenhire.ai/request-demo?source=api-docs)
-------------------------------------------------------------------------------- ## Get one candidate's status Source: https://platform.zenhire.ai/docs/cv-deepsearch/get-cvds-candidate.api/ > Return one candidate's embedding status (plus its `tags`). Like the Return one candidate's embedding status (plus its `tags`). Like the list endpoint, the candidate's `parsed_cv` is never returned — you already hold it. A candidate that doesn't exist in the named corpus, or belongs to another client, returns `404` (no existence leak). Request -------------------------------------------------------------------------------- ## Poll a search run Source: https://platform.zenhire.ai/docs/cv-deepsearch/get-cvds-run.api/ > Return the current state of a search run. Cross-tenant requests return Return the current state of a search run. Cross-tenant requests return `404` to avoid leaking the existence of other clients' run ids. > **Results pending.** The best-first candidate results, and the > companion `GET /api/v1/cvds/runs/{id}/results` pagination endpoint, > are finalized in a follow-up release. Until then this endpoint returns > the run's status and echoed inputs with `results: null` and > `results_pending: true`. Request -------------------------------------------------------------------------------- ## Ingest candidates into a corpus Source: https://platform.zenhire.ai/docs/cv-deepsearch/ingest-cvds-candidates.api/ > Batch upsert parsed CVs into a corpus. Each candidate is keyed by Batch upsert parsed CVs into a corpus. Each candidate is keyed by `(corpus_id, external_id)` and idempotently upserted — re-ingesting a candidate whose content is unchanged is a no-op (`unchanged`), while a changed `parsed_cv` re-embeds it (`updated`). ### Corpus resolution (fail-closed) Provide `corpus_id` at the top level (applies to every candidate) or per-candidate (overrides the top-level value). A candidate with no resolvable `corpus_id` rejects the **whole batch** with `MISSING_CORPUS_ID` — it is never merged into a default corpus. ### Request shape `candidates` is an **array** of `{ external_id, parsed_cv, … }` items — send a single candidate as a one-element array. Set `corpus_id` once at the top level (applies to every candidate) or per-candidate. ### Limits - **Up to 500 candidates** per request (`BATCH_TOO_LARGE` above that). - **~10 MB request body** per request (`PAYLOAD_TOO_LARGE` above that). For larger corpora, split into multiple requests (≤ 500 candidates and ≤ ~10 MB each) or use bulk sync. Small batches embed inline and return embedded statuses directly; larger batches accept fast with `pending` embedding statuses and embed in the background — poll `GET /api/v1/cvds/candidates/{external_id}` for the final status. ### Required permission Client's `permissions[]` must contain `cvdeepsearch`. Without it the request returns `403 MISSING_PERMISSION`. Request -------------------------------------------------------------------------------- ## Ingestion Source: https://platform.zenhire.ai/docs/cv-deepsearch/ingestion.tag/ > Ingestion Fill the corpus — batch-ingest parsed CVs and check each candidate's embedding status. ```mdx-code-block ``` -------------------------------------------------------------------------------- ## List candidates in a corpus Source: https://platform.zenhire.ai/docs/cv-deepsearch/list-cvds-candidates.api/ > Paginated status list of the candidates in a corpus, newest first. Paginated status list of the candidates in a corpus, newest first. Cursor-based: pass the previous response's `next_cursor` back as `?cursor=` for the next page. The candidate's `parsed_cv` (the PII you ingested) is **never** returned by this endpoint — only the embedding status and content hash. Request -------------------------------------------------------------------------------- ## overview Source: https://platform.zenhire.ai/docs/cv-deepsearch/overview/ --- id: overview title: CV DeepSearch overview sidebar_label: Overview sidebar_position: 1 description: Search a corpus of parsed CVs for the best-matching candidates against a position — ingest once, then run repeated searches. --- # CV DeepSearch overview CV DeepSearch is the **inverse of CV DeepMatch**. CV DeepMatch scores *one CV against one job*; CV DeepSearch lets you **ingest a corpus of candidates once** and then run repeated **searches** that return the best-first candidates for a given position. **Base URL:** `https://platform.zenhire.ai/api/v1/cvds` ## Two flows CV DeepSearch is **two distinct flows** — fill the corpus, then query it. Each has its own guide: ``` Flow 1 — Ingestion (fill the corpus) → see the Ingestion guide Ingest a corpus POST /api/v1/cvds/candidates (batch parsed CVs) → per-candidate embedding status Check status GET /api/v1/cvds/candidates[/{external_id}] Flow 2 — Search (query the corpus) → see the Search guide Run a search POST /api/v1/cvds/search for one position → run id (status: pending) Consume the results GET /api/v1/cvds/runs/{id} (poll) (or receive the completion webhook) ``` You ingest a corpus once and keep it warm; searching it for different positions is cheap and repeatable. Re-ingest only when a candidate's CV changes. - [**Ingestion guide**](/guides/cv-deepsearch-ingestion) — fill the corpus. - [**Search guide**](/guides/cv-deepsearch-search) — query the corpus. ## Core concepts | Concept | What it is | |---|---| | **Corpus** | A named pool of candidates (`corpus_id`, chosen by you). A search only ever reads candidates in the named corpus. Corpora are tenant-scoped — yours are never visible to other clients. | | **Candidate** | One parsed CV in a corpus, keyed by your own `external_id`. Each candidate is embedded for semantic retrieval. | | **Embedding status** | `pending` → `embedded` (searchable) → or `failed`. A candidate is only returned by a search once `embedded`. | | **Search run** | One search of a corpus for one position. Triggering a search returns a run `id`; the search runs asynchronously. | | **Position config** | The `position_metadata` you search against — the **same shape CV DeepMatch consumes** as its `requirements` config. | ## Authentication Authenticate every call with your platform **API key** in the `X-API-Key` header — the same model as Speech, Interview, and CV DeepMatch. Your client needs the **`cvdeepsearch` permission** enabled; calls without it return `MISSING_PERMISSION`. ``` X-API-Key: zh_api_… ``` An API key is pinned to its own client (no cross-tenant access). The same endpoints also back the ZenHire console UIs, which use a logged-in browser session instead — both modes share one contract. See [Authentication](/universal/authentication) for key management. ## Corpus isolation (fail-closed) `corpus_id` is **mandatory** on ingest and search. A request with no resolvable `corpus_id` is rejected with `MISSING_CORPUS_ID` — it is **never** merged into a default corpus. This is deliberate: it prevents one client's candidates from silently leaking into another search context. ## Error responses All CV DeepSearch endpoints use the platform's structured error envelope: ```json { "error": { "code": "MISSING_CORPUS_ID", "message": "corpus_id is mandatory and fail-closed.", "timestamp": "2026-06-09T10:00:00.000Z", "details": {} } } ``` See [Errors](/universal/errors) for the shared envelope, and the [Ingestion](/guides/cv-deepsearch-ingestion) and [Search](/guides/cv-deepsearch-search) guides for the per-code catalog. The full endpoint reference is the **CV DeepSearch** section in the API reference sidebar. :::note Results side is in progress The per-run **results** — the best-first candidate list on `GET /api/v1/cvds/runs/{id}`, the `GET /api/v1/cvds/runs/{id}/results` pagination endpoint, and the final webhook completion payload — are finalized in a follow-up release. Until then the run poll returns the run's status with `results: null` and `results_pending: true`. The ingest, search-trigger, and run-status sides documented here are final. ::: -------------------------------------------------------------------------------- ## CvdsCandidateStatus Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdscandidatestatus.schema/ > A candidate's embedding status as returned by the list endpoint. Never A candidate's embedding status as returned by the list endpoint. Never includes `parsed_cv`. -------------------------------------------------------------------------------- ## CvdsCandidateStatusDetail Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdscandidatestatusdetail.schema/ -------------------------------------------------------------------------------- ## CvdsError Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdserror.schema/ > Standard error envelope returned by every CV DeepSearch endpoint on a non-2xx response. Standard error envelope returned by every CV DeepSearch endpoint on a non-2xx response. -------------------------------------------------------------------------------- ## CvdsIngestCandidate Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdsingestcandidate.schema/ > One candidate to ingest. One candidate to ingest. -------------------------------------------------------------------------------- ## CvdsIngestRequest Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdsingestrequest.schema/ > JSON body for `POST /api/v1/cvds/candidates`. Up to 500 candidates per JSON body for `POST /api/v1/cvds/candidates`. Up to 500 candidates per request. `corpus_id` may be set at the top level (applies to all candidates) and/or per candidate (overrides the top-level value); every candidate must resolve to a `corpus_id` or the whole batch is rejected (`MISSING_CORPUS_ID`). -------------------------------------------------------------------------------- ## CvdsIngestResponse Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdsingestresponse.schema/ > Per-candidate ingest result plus a roll-up summary. Per-candidate ingest result plus a roll-up summary. -------------------------------------------------------------------------------- ## CvdsIngestResult Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdsingestresult.schema/ -------------------------------------------------------------------------------- ## CvdsIngestSummary Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdsingestsummary.schema/ > Roll-up counts across the batch. Roll-up counts across the batch. -------------------------------------------------------------------------------- ## CvdsPositionMetadata Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdspositionmetadata.schema/ > The position-config object a search plans against (the The position-config object a search plans against (the `position_metadata` field on `POST /api/v1/cvds/search`). This is the **same shape CV DeepMatch consumes** as its `requirements` config — see the [`CvDeepMatchRequirements`](/cv-deepmatch/schemas/cvdeepmatchrequirements) schema and the [Position config reference](/guides/cv-deepmatch-position-config) for the complete field-by-field contract (the 1–5 importance scale, where each requirement belongs, education ON/OFF, the language-config quirks, and the cardinality limits). The API requires only that `position_metadata` be a **non-empty object** — the inner shape is the matching/search engine's contract, not validated field-by-field at this layer. Typically it carries a `name` and a `requirements` block: ```json { "name": "Senior Backend Engineer", "requirements": { "workExperience": { "from": 5, "to": 10, "importance": 5, "relevant_industries": ["SaaS"], "industries_config": [{ "name": "SaaS", "importance": 5 }] }, "skills": { "importance": 5, "skills_config": { "hard_skills": [{ "name": "Node.js", "importance": 5 }], "requirements": { "minimal_qualifications": true, "preferable_qualifications": true } } } } } ``` -------------------------------------------------------------------------------- ## CvdsRun Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdsrun.schema/ > State of a single search run, as returned by State of a single search run, as returned by `GET /api/v1/cvds/runs/{id}`. The results fields are **pending** the results pipeline — until it ships, `results` is `null` and `results_pending` is `true`. -------------------------------------------------------------------------------- ## CvdsSearchRequest Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdssearchrequest.schema/ > JSON body for `POST /api/v1/cvds/search`. JSON body for `POST /api/v1/cvds/search`. -------------------------------------------------------------------------------- ## CvdsSearchResponse Source: https://platform.zenhire.ai/docs/cv-deepsearch/schemas/cvdssearchresponse.schema/ > Immediate response to a triggered search. Immediate response to a triggered search. -------------------------------------------------------------------------------- ## Search Source: https://platform.zenhire.ai/docs/cv-deepsearch/search.tag/ > Search Query the corpus — trigger a search against a corpus for one position, and read the run. ```mdx-code-block ``` -------------------------------------------------------------------------------- ## Trigger a search against a corpus Source: https://platform.zenhire.ai/docs/cv-deepsearch/submit-cvds-search.api/ > Start a search of one corpus for the best-first candidates against a Start a search of one corpus for the best-first candidates against a position. Returns immediately with a run `id` and `status: "pending"`; the search runs asynchronously. Read the result by polling `GET /api/v1/cvds/runs/{id}` or via the completion webhook. ### Position config (`position_metadata`) `position_metadata` is the position-config object the search plans against. It is the **same shape CV DeepMatch consumes** as its `requirements` config — see the `CvdsPositionMetadata` schema and the [Position config reference](/guides/cv-deepmatch-position-config) for the field-by-field contract (the 1–5 importance scale, where each requirement belongs, education ON/OFF, cardinality limits). The search does not constrain the inner shape beyond requiring a non-empty object. ### Job updates & caching The search planner caches a plan per `position_id`. Set `job_updated: true` when the position's requirements changed since the last search so the plan is rebuilt; leave it `false` (default) to reuse the cached plan and re-rank. ### `top_n` How many best-first candidates to return (default 100). Values above the server cap (1000) are clamped. ### Webhook URL requirements - Optional. Omit to poll-only. - When provided, must use `https://` (`INSECURE_WEBHOOK_URL` otherwise) and must not resolve to a private / loopback / link-local address (`PRIVATE_WEBHOOK_URL`). DNS is re-checked before each delivery attempt (DNS-rebinding defense). ### Idempotency Pass an `idempotency_key` to make the trigger safe to retry — the same key replays the existing run `id` instead of starting a new search. ### Required permission Client's `permissions[]` must contain `cvdeepsearch` (`403 MISSING_PERMISSION` otherwise). Request -------------------------------------------------------------------------------- ## ZenHire CV DeepSearch API Source: https://platform.zenhire.ai/docs/cv-deepsearch/zenhire-cv-deepsearch-api.info/ > Search a corpus of parsed CVs for the best-matching candidates against a Search a corpus of parsed CVs for the best-matching candidates against a position. CV DeepSearch is the inverse of CV DeepMatch: instead of scoring one CV against one job, you **ingest a corpus of candidates once**, then run repeated **searches** that return the best-first candidates for a given position. The integration is three steps: 1. **Ingest** — batch-upload parsed CVs into a corpus (`POST /api/v1/cvds/candidates`). Each candidate is embedded for semantic retrieval; you poll the embedding status per candidate. 2. **Search** — trigger a search against the corpus for one position (`POST /api/v1/cvds/search`). Returns a run `id`; the search runs asynchronously. 3. **Consume** — poll the run (`GET /api/v1/cvds/runs/{id}`) or receive the completion webhook to read the best-first results. All endpoints require an `X-API-Key` header. The calling client's `permissions[]` must contain `cvdeepsearch` — contact support to enable. ### Corpus isolation Every candidate and search is scoped to a **corpus** (`corpus_id`), which you choose. A search only ever reads candidates in the named corpus, and corpora belonging to other clients are never visible. `corpus_id` is **mandatory** on ingest and search; a missing one is rejected (`MISSING_CORPUS_ID`) rather than silently merged into a default pool. ### Webhook callback Pass a `webhook_url` on a search to receive a signed `POST` when the run finishes (HMAC-SHA256 in the `X-CVDM-Signature` header, Stripe-style `t=,v1=` — the same scheme as CV DeepMatch). The completion payload shape is finalized alongside the results pipeline; see the [Search guide](/guides/cv-deepsearch-search) for the current envelope and the verification snippet. > **Results side (pending):** the per-run results — the best-first candidate > list on `GET /api/v1/cvds/runs/{id}`, the `GET /api/v1/cvds/runs/{id}/results` > pagination endpoint, and the final webhook completion payload — are > finalized in a follow-up release. Until then, the run poll returns the > run's status with `results: null` and `results_pending: true`.
API key issued in the ZenHire dashboard. Format: `zh_live_…` or `zh_test_…`.
Security Scheme Type: apiKey
Header parameter name: X-API-Key

Contact

Book a demo: URL: [https://zenhire.ai/request-demo?source=api-docs](https://zenhire.ai/request-demo?source=api-docs)
-------------------------------------------------------------------------------- ## Create a persona Source: https://platform.zenhire.ai/docs/interview-api/create-persona.api/ > Creates a reusable interviewer persona for your client. You supply the Creates a reusable interviewer persona for your client. You supply the `role_name` and the interview `language`. By default the AI **system prompt is generated server-side** from ZenHire's v1 interviewer template for that role and language. Prompt authoring is **optional**: supply your own `system_prompt` to store verbatim instead of the generated one, and/or `base_prompt_override` to replace the global behavioral base prompt (turn-taking / pacing layer). Omit both to inherit ZenHire's defaults. The persona is owned by your client and can be reused across any number of candidate sessions. Request -------------------------------------------------------------------------------- ## Start a session Source: https://platform.zenhire.ai/docs/interview-api/create-session.api/ > Starts an interview session against a persona for one candidate and Starts an interview session against a persona for one candidate and mints a **candidate link**. Before the session is created, the platform runs two pre-checks: the `interview` permission must be enabled and the client must have at least the minimum credit balance. Sessions started with an `X-API-Key` inherit that key's project attribution automatically; there is no `project_id` body field on this request (project selection is a console-only input). Creating a session only mints a candidate link; it does **not** consume a concurrent-interview slot. You can mint as many links as you like (each is bounded only by its `expires_in` token TTL). The concurrent-interview cap (`maxConcurrentRequests`) is enforced later, when a candidate actually starts the live interview — see the session lifecycle note on the verify endpoint. On success you receive a `candidate_link` of the form `https://interview.zenhire.ai/#t=`. The token travels in the URL **fragment** (`#t=`), not a query string, so it is never sent to a server in a plain link preview or by email/link scanners. The token is: - **per-candidate** — it admits a single candidate to one interview. Reloads and brief reconnects are tolerated, but once the interview reaches a terminal state (`completed`/`failed`) the link no longer starts a new interview. - **time-limited** — it expires `expires_in` seconds after creation (a 7-day pre-use TTL). An unused, expired token cannot start an interview. **Maximum interview duration.** Once a candidate starts the live interview, it must complete within a fixed time budget. A live interview that runs past that budget without a completion signal is automatically closed (status `failed`, error code `SESSION_TIMEOUT`), which also frees its concurrent-interview slot. A session that ended this way — or any session that ended **without a result** — can be re-invited from the ZenHire console, which mints a fresh candidate link. Send the link to the candidate by whatever channel you prefer. The raw token is only ever returned in this response — the platform stores only a hash of it and never logs or re-emits it. Request -------------------------------------------------------------------------------- ## Get a persona Source: https://platform.zenhire.ai/docs/interview-api/get-persona.api/ > Returns a single persona, including its generated `system_prompt`. Returns a single persona, including its generated `system_prompt`. A `client_member` can only access personas owned by its own client (cross-tenant access returns `403`). Request -------------------------------------------------------------------------------- ## Get recording Source: https://platform.zenhire.ai/docs/interview-api/get-session-recording.api/ > Streams the session's **audio recording** back to you as raw audio Streams the session's **audio recording** back to you as raw audio bytes (`audio/wav`). Point an `