Skip to main content

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).

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 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 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 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 (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:

FieldLimit
hard_skills0–5 entries
soft_skills0–5 entries
hard_skills + soft_skills combined≤ 10 entries
minimal_qualifications0–3 entries
preferable_qualifications0–3 entries
Every importance valueinteger 1–5
study_statusGraduate / 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
110 s after submit
215 s
330 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:

HeaderValue
Content-Typeapplication/json
X-CVDM-Request-Idapi_<id> — correlates with your run's id.
X-CVDM-Signaturet=<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 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

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:

FieldTypeMeaning
overall_scorefloatAggregate match score, 0–1.
breakdown.workExperiencefloatHow well the candidate's experience matches the workExperience block.
breakdown.skillsfloatHow well the candidate's skills match the skills_config list.
breakdown.educationfloatPresent 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:

{
"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.codeHTTPMeaningRetry?
INVALID_INPUT400A field failed validation. See error.details.fields.After fix
INVALID_FILE_CONTENT400cv_file did not start with a valid PDF magic header.After fix
INSECURE_WEBHOOK_URL400webhook_url is http://. Use HTTPS.After fix
PRIVATE_WEBHOOK_URL400webhook_url resolves to a private / loopback address.After fix
MISSING_PERMISSION401 / 403Missing API key, invalid API key, or missing cvdeepmatch perm.Contact support
INSUFFICIENT_CREDITS402Balance below the per-match minimum. error.details has the gap.Top up
REQUEST_NOT_FOUND404No request with this id for your client.No
RATE_LIMITED429Poll interval too short. Honour Retry-After.After backoff
STEP_FUNCTIONS_FAILED502Downstream pipeline failed to start.Yes, with same idempotency_key
INTERNAL_ERROR500Uncategorised server error.After delay

Next steps

  • Read the Position config reference for how to shape the requirements config 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.