# 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 `