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_urland pollGET /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_urland 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).
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. The key looks like
zh_api_…. Send it on every request as theX-API-Keyheader. - The
cvdeepmatchpermission on your client. Contact support if your key returnsMISSING_PERMISSION. - A CV in PDF format, 5 MB or smaller. DOCX is not supported at launch (see 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.
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 for details.
Step 1 — Submit a match
POST https://platform.zenhire.ai/api/v1/cvdeepmatch/submit (multipart).
curl
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:
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):
{
"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;
you'll need it to poll or to correlate the webhook callback.
Node.js (fetch + FormData)
import fs from "node:fs";
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)
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 (orposition_namealias).requirements.workExperience.from,.to— integer years (must satisfyfrom ≤ 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 perrelevant_industriesentry.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
guide, with the per-field schema at
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}.
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_<id> — correlates with your run's id. |
X-CVDM-Signature | t=<unix-ts>,v1=<hex> — Stripe-style HMAC-SHA256. |
X-CVDM-Signature-Timestamp | <unix-ts> — same value as the t= field in the signature. |
Body shape (representative):
{
"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=<unix-ts>,v1=<hex>,
where <hex> is the lower-case hex HMAC-SHA256 of the string
<unix-ts>.<compact-JSON-body> (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 yourwebhook_urlis 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
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // 5 minutes
export function verifyWebhook(req, rawBody, secret) {
const header = req.headers["x-cvdm-signature"]; // 't=<ts>,v1=<hex>'
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
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_INPUTtoday. 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_urlthat 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 withPRIVATE_WEBHOOK_URL. The DNS resolution is re-checked before each delivery attempt to defend against DNS rebinding. - Minimum poll interval is 10 seconds per
idwhile a match isprocessing. Faster polls return 429.
Error codes
Every error response uses the shared standard error envelope:
{
"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
for how to shape the
requirementsconfig correctly (importance scale, field routing, education, qualifications, cardinality limits). - Browse the full CV DeepMatch API reference for every parameter, schema, and example.
- See the API authentication guide for key rotation and IP allow-listing.
- For interactive exploration, use the Try it console on each endpoint reference page.