Screening is the part of hiring that scales worst. Ten CVs for one role is an afternoon; a hundred is a week. This tutorial builds a small Python script that takes a folder of resumes and a job description, and prints a ranked shortlist with a reason for each position.
It uses the HireLayer recruiting APIs, which I build. Everything below runs on the free plan (50 credits a month, no card), and each successful call costs one credit.
What we are building
cvs/ job.txt
├─ alice.pdf │
├─ bob.docx ▼
└─ chloe.png extract criteria ──┐
│ │
▼ ▼
parse each CV ──► resume text ──► match each candidate ──► per-criterion evidence
│
└──────► rank all candidates ──► ordered shortlist
Four endpoints, all synchronous JSON (the reference is at hirelayer.co/api-docs, with an OpenAPI spec if you prefer generating a client):
| Step | Endpoint | Returns |
|---|---|---|
| Parse a CV | POST /api/v3/parser |
Structured profile + full text in info_resume.text
|
| Job criteria | POST /api/v1/jobs/extract-criteria |
matching_criteria[] with a weight from 1 to 3 |
| Match | POST /api/v1/matching/job-candidate |
score from 0 to 1 + one verdict per criterion |
| Rank | POST /api/v1/matching/job-candidates/rank |
Up to 10 candidates ordered by rank
|
Setup
pip install requests
export HIRELAYER_API_KEY="your-key" # from the dashboard after signing up
1. Parse the resumes
The parser accepts PDF, DOC, DOCX, ODT, PPT, PPTX, ODP, XLS, RTF, TXT, JPG, PNG and BMP files under 4.5 MB, and detects the type from the content. do_not_store_data=true tells the API not to keep the file after parsing, which is what you want for a local script.
import os
from pathlib import Path
import requests
API = "https://hirelayer.co"
HEADERS = {"X-API-Key": os.environ["HIRELAYER_API_KEY"]}
def parse_resume(path: Path) -> dict:
with path.open("rb") as f:
r = requests.post(
f"{API}/api/v3/parser",
headers=HEADERS,
files={"file": (path.name, f)},
data={"do_not_store_data": "true"},
timeout=120,
)
r.raise_for_status()
return r.json()
The response has the candidate profile (info_candidate), work_experiences, educations, languages, skills and the full text, including OCR for scanned or image CVs, in info_resume.text. That text is what the matching endpoints take.
2. Turn the job description into criteria
def extract_criteria(job_text: str) -> list[dict]:
r = requests.post(
f"{API}/api/v1/jobs/extract-criteria",
headers=HEADERS,
json={"job_text": job_text},
timeout=60,
)
r.raise_for_status()
return r.json()["matching_criteria"]
A sentence like "React required. Fluent English required. Paris-based." comes back as three criteria, sorted by weight, each with id, label, weight (3 essential, 2 important, 1 nice to have), is_mandatory and a rationale. You can edit the list before matching: drop a criterion, change a weight, add your own with any id.
3. Score one candidate, criterion by criterion
def match(job_text: str, resume_text: str, criteria: list[dict]) -> dict:
r = requests.post(
f"{API}/api/v1/matching/job-candidate",
headers=HEADERS,
json={
"job_text": job_text,
"candidate_text": resume_text,
"matching_criteria": criteria,
},
timeout=60,
)
r.raise_for_status()
return r.json()
Each entry of evaluated_criteria gets a match_status (ideal, potential or not_mentioned) and a match_explanation quoting the evidence, and score is the weighted average. This is the call to use when a recruiter needs to see why, not just a number. Note that the summary and explanations are currently written in French.
4. Rank the whole batch
Ranking compares candidates against each other for the same job, up to 10 per call:
def rank(job_text: str, resumes: dict[str, str]) -> list[dict]:
r = requests.post(
f"{API}/api/v1/matching/job-candidates/rank",
headers=HEADERS,
json={
"job_text": job_text,
"candidates": [
{"id": cid, "candidate_text": text} for cid, text in resumes.items()
],
},
timeout=120,
)
r.raise_for_status()
return r.json()["rankings"]
Putting it together
def main(cv_dir: str = "cvs", job_file: str = "job.txt") -> None:
job_text = Path(job_file).read_text()
resumes = {
p.stem: parse_resume(p)["info_resume"]["text"]
for p in sorted(Path(cv_dir).iterdir())
if p.is_file()
}
criteria = extract_criteria(job_text)
print("Criteria:")
for c in criteria:
print(f" [{c['weight']}] {c['label']}")
rankings = rank(job_text, resumes)
print("\nShortlist:")
for row in rankings:
print(f" #{row['rank']} {row['candidate_id']} ({row['score']:.2f}) {row['rationale']}")
best = rankings[0]["candidate_id"]
detail = match(job_text, resumes[best], criteria)
print(f"\n{best}: {detail['score']:.2f}")
for c in detail["evaluated_criteria"]:
print(f" {c['match_status']:>13} {c['label']}")
if __name__ == "__main__":
main()
For 8 CVs this costs 8 (parse) + 1 (criteria) + 1 (rank) + 1 (match) = 11 credits. In a real pipeline you would cache the parsed text so a CV is only parsed once, even if it is matched against several jobs.
Practical notes
- Batches over 10. Rank takes 10 candidates per call. For larger pools, score everyone with Match first and send the top 10 to Rank.
-
Errors. Non-2xx responses carry a JSON error. Treat
429as retryable with a backoff; a401means theX-API-Keyheader is missing or wrong (Authorization: Beareris not accepted). -
Skills. If you store profiles,
POST /api/v1/skills/resolvemaps free-text skills ("Pack Office (Word, Excel)", "React.js") to taxonomy IDs, which makes search and facets much easier than raw strings.
Using it from an AI assistant instead
If you would rather ask Claude, Cursor or VS Code to "rank the CVs in ~/candidates for this job", the same five endpoints are wrapped in an open-source MCP server: github.com/louisdesc/hirelayer-mcp.
Questions or edge cases you hit with real CVs are very welcome in the comments.
Top comments (0)