AI resume screening reads every application for a job and tells a recruiter which ones meet the requirements, and why. Done well, it gives each CV the same careful read as the first ten in the inbox. Done badly, it is a keyword filter with a confident score attached. This guide explains how AI resume screening works, then walks through a Python program that screens a folder of resumes against one job description with the HireLayer APIs, from the parsed CV to an explained shortlist.
How AI resume screening works
Most screening tools, whatever they call themselves, chain the same four steps. Knowing them helps you judge a vendor or build your own feature.
| Step | What it produces | HireLayer endpoint |
|---|---|---|
| 1. Parse each resume | A structured record plus the full text of the CV, including scans | POST /api/v3/parser |
| 2. Turn the job into criteria | Weighted requirements, each flagged mandatory or not | POST /api/v1/jobs/extract-criteria |
| 3. Evaluate each candidate | A status and the evidence for every criterion, and a 0–1 score | POST /api/v1/matching/job-candidate |
| 4. Order the shortlist | Up to 10 candidates ranked side by side, each with a rationale | POST /api/v1/matching/job-candidates/rank |
The order matters. Criteria are written once per job and reviewed by a person before any candidate is scored, so every application is judged against the same list. The decision to advance or reject someone stays outside the API, in your product, with a name next to it.
Keyword filters versus criterion-based screening
A keyword filter asks whether a word appears in the CV. “Led a support team of six” doesn't contain “people management”, so the filter drops a candidate a recruiter would have kept. It also rewards CVs stuffed with the job ad’s vocabulary, wich is exactly what job seekers are told to do.
Criterion-based screening asks a different question for each requirement: does the resume show it, partly show it, say nothing, or contradict it? HireLayer Match returns one of four statuses per criterion, with a sentence of evidence from the CV:
ideal: clearly met.potential: partly or indirectly met.not_mentioned: the resume says nothing about it.not_valid: the resume contradicts it.
The difference between not_mentioned and not_valid
is the one that matters most in practice. A CV that never mentions a driving
licence is not the same as a CV that says the candidate has none, and your
screen shouldn't treat them the same way.
Build the screener in Python
The program below reads a job description from job.txt, screens
every resume in an applications folder and writes the results to
screening.json for a recruiter to review. You need Python 3.9 or
later, pip install requests and an API key in the
HIRELAYER_API_KEY environment variable. Keep the key on the
server; it never belongs in browser code.
The client
Save this as hirelayer.py. It sends the key in the
X-API-Key header, retries server errors and dropped connections
with backoff, and raises an error that carries the HTTP status and the request
ID for support. It's the same client as in the
screening pipeline guide.
# hirelayer.py — minimal client with retries. Requires: pip install requests
import mimetypes
import os
import random
import time
import requests
API_BASE = "https://hirelayer.co/api"
class HireLayerError(Exception):
def __init__(self, status, message, code=None, request_id=None):
super().__init__(f"HireLayer {status}: {message}")
self.status = status
self.code = code
self.request_id = request_id
def _delay(attempt, retry_after):
if retry_after and retry_after.isdigit():
return int(retry_after)
return min(30, 2**attempt) + random.random()
def call(method, path, *, json=None, files=None, data=None, timeout=65, max_retries=3):
"""Call HireLayer. Retries 5xx and connection failures, raises HireLayerError otherwise."""
headers = {"X-API-Key": os.environ["HIRELAYER_API_KEY"]}
for attempt in range(max_retries + 1):
try:
response = requests.request(
method,
API_BASE + path,
headers=headers,
json=json,
files=files,
data=data,
timeout=timeout,
)
except requests.ConnectionError:
if attempt == max_retries:
raise
time.sleep(_delay(attempt, None))
continue
if response.ok:
return response.json()
if response.status_code >= 500 and attempt < max_retries:
time.sleep(_delay(attempt, response.headers.get("Retry-After")))
continue
try:
body = response.json()
except ValueError:
body = {}
raise HireLayerError(
response.status_code,
body.get("error", response.reason),
body.get("code"),
response.headers.get("x-parser-request-id"),
)
def parse_resume(path, application_id=None):
content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
with open(path, "rb") as file:
content = file.read() # bytes can be re-sent on retry
return call(
"POST",
"/v3/parser",
files={"file": (os.path.basename(path), content, content_type)},
data={"application_id": application_id} if application_id else None,
timeout=150,
)
The screening program
Save this as screen.py and run python screen.py. The
first run extracts the criteria and stops, so a recruiter can edit
criteria.json before anything is scored: drop a requirement the
hiring manager doesn't really care about, lower a weight, or clear a mandatory
flag. The second run does the screening.
# screen.py — screen every resume in ./applications against one job.
import json
from pathlib import Path
from hirelayer import HireLayerError, call, parse_resume
MAX_TEXT = 50_000 # Match and Rank limit per resume
RESUME_EXTENSIONS = {".pdf", ".doc", ".docx", ".odt", ".ppt", ".pptx", ".odp", ".xls", ".rtf", ".txt", ".jpg", ".jpeg", ".png", ".bmp"}
job_text = Path("job.txt").read_text(encoding="utf-8")
# 1. Turn the job into criteria once, then let a recruiter review them.
criteria_file = Path("criteria.json")
if not criteria_file.exists():
criteria = call("POST", "/v1/jobs/extract-criteria", json={"job_text": job_text})
criteria_file.write_text(
json.dumps(criteria["matching_criteria"], ensure_ascii=False, indent=2),
encoding="utf-8",
)
raise SystemExit("Review criteria.json (labels, weights, is_mandatory), then run again.")
criteria = json.loads(criteria_file.read_text(encoding="utf-8"))
# 2. Parse each application and evaluate it against the same criteria.
results = []
for path in sorted(Path("applications").iterdir()):
if path.suffix.lower() not in RESUME_EXTENSIONS:
continue
try:
resume = parse_resume(str(path), application_id=path.stem)
except HireLayerError as error:
if error.status == 422: # not a resume, unreadable or empty: a person checks it
results.append({"id": path.stem, "status": "manual_review"})
continue
raise
text = resume["info_resume"]["text"][:MAX_TEXT]
match = call(
"POST",
"/v1/matching/job-candidate",
json={"job_text": job_text, "candidate_text": text, "matching_criteria": criteria},
)
missing = [
criterion["label"]
for criterion in match["evaluated_criteria"]
if criterion["is_mandatory"] and criterion["match_status"] == "not_valid"
]
results.append({
"id": path.stem,
"status": "flagged" if missing else "scored",
"score": match["score"],
"summary": match["summary"],
"missing_mandatory": missing,
"criteria": match["evaluated_criteria"],
"text": text,
})
# 3. Order the scored pool. Match scores share the same criteria, so they compare.
scored = sorted(
(r for r in results if r["status"] == "scored"), key=lambda r: r["score"], reverse=True
)
# 4. Optional: rank the top 10 side by side for a relative order with rationales.
if len(scored) > 1:
rankings = call(
"POST",
"/v1/matching/job-candidates/rank",
json={
"job_text": job_text,
"candidates": [{"id": r["id"], "candidate_text": r["text"]} for r in scored[:10]],
},
)["rankings"]
for item in rankings:
print(item["rank"], item["candidate_id"], round(item["score"], 2), item["rationale"])
# 5. Save everything for the recruiter's review screen. Nobody is rejected here.
for result in results:
result.pop("text", None)
Path("screening.json").write_text(
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
)
Each application costs two credits, one to parse it and one to evaluate it, plus one for the criteria and one for the optional ranking. Parsing takes about 35 seconds per file, so for more than a handful of CVs run the loop in a background worker and process a few files in parallel. The bulk parsing guide covers queues, retries and duplicates.
What one evaluated criterion looks like
Every criterion you send comes back in the same order with its status and explanation. Explanations and summaries are written in French in the current version, so translate them in your interface if your recruiters work in another language.
{
"id": "crit_4",
"label": "Anglais courant",
"weight": 2,
"is_mandatory": true,
"rationale": "Un anglais courant est demandé.",
"match_status": "potential",
"match_explanation": "Le CV mentionne un anglais professionnel, sans préciser un niveau courant."
}
Run the screening flow on your own CVs
Create a free account, put a real job description in job.txt and a few anonymized resumes in the folder, then compare the shortlist with your recruiters’ own picks.
Scores, weights and mandatory requirements
The Match score is a weighted average you can recompute yourself:
ideal counts 1, potential 0.6,
not_mentioned 0.5 and not_valid 0, each multiplied
by the criterion’s weight (3 essential, 2 important, 1 nice to have) and
divided by the sum of the weights. Because the formula is fixed and the
criteria are the same for everyone, scores from seperate calls for the same
job can be compared.
The is_mandatory flag does not change the score. That's on
purpose: a hard requirement is a business rule, and business rules belong in
your code where you can see and change them. The program above flags any
candidate with a mandatory criterion marked not_valid instead of
dropping them, so a recruiter confirms the reading before anyone is rejected.
A not_mentioned mandatory criterion is a question for the
candidate, not a reason to reject.
Screening more than 10 applications
Rank orders up to 10 candidates in one request, and its scores only compare candidates inside that request. For a pool of 200 applications, the program uses Match for everyone, sorts by the Match score, then asks Rank to order the top 10 side by side. Honestly, for many teams the Match order is enough and the Rank step is a nice extra: it adds a short rationale comparing each finalist with the others, which helps when two profiles score close together.
If your product already has a search index, you can also pre-filter there (location, work authorization, availability) and only send the remaining applications to Match. Those filters are facts the candidate gave you, so they are easier to explain than a model’s judgement.
Keep a person in charge of the decision
Screening affects who gets a job interview, so build the review step before you build the automation. A few rules hold up well:
- Show the evidence, not just the number. A recruiter should see each criterion’s status and the sentence behind it, and be able to disagree.
- Never auto-reject. Flag, sort and summarize; a person clicks the button that ends an application, and your product logs who did.
- Review the criteria, not only the results. Most bad shortlists start with a requirement nobody needed, like a degree for a role that never used one.
- Check the outcomes. Compare who gets advanced with who applied, by stage, and look into any gap you can’t explain.
In the EU, AI systems that filter applications or evaluate candidates are listed as high-risk under the AI Act, with obligations for providers and for the employers who deploy them. Our EU AI Act checklist for recruiting software goes through what that means for a screening feature. In the US, check the local rules that apply to automated employment decision tools where you hire, such as New York City’s bias audit requirement.
Production checklist
- Call the APIs from a background job. The recruiter's screen reads stored results; it doesn't wait for a parse.
- Store the criteria with the job and the parsed JSON with the candidate. Re-running a call costs a credit and the explanation text can vary slightly between runs.
- Use your own application IDs as
application_idand as Rankids, so every result maps back to a record. - Treat a
422from the parser as a final outcome for that file (not a resume, unreadable or empty) and route it to a person. - Send
do_not_store_data=trueif you keep your own copy of the files, so HireLayer does not store the resume file. - Re-screen when the job changes. New criteria mean new scores; old ones should be marked out of date in your interface.
Finaly, measure before you roll out. Take a closed job with a known shortlist, run the program on its applications and compare the ouput with what your recruiters chose. Where they disagree, read the criterion explanations: they usually point to a requirement that needs rewording.
Frequently asked questions
What is AI resume screening?
Software that reads job applications and assesses them against the requirements of a role, usually by parsing the CV, comparing it with criteria taken from the job description and returning a score or an order. A recruiter then reviews the result and makes the decision.
Is AI resume screening better than keyword filtering?
It catches equivalent experience that a keyword filter misses, and it can say why a requirement is or isn’t met. It still needs a person to review the criteria and the shortlist, because it can misread a CV like any reader.
How many resumes can I screen in one API call?
Parsing and Match handle one resume per call. Rank orders up to 10 candidates for one job in a single call. For larger pools, evaluate every candidate with Match against the same criteria and sort by score.
Can the screener reject candidates automatically?
The API returns statuses, scores and reasons; it never rejects anyone. We recommend flagging candidates who miss a mandatory requirement and letting a recruiter confirm, rather than rejecting them in code.
Which languages and file formats are supported?
The parser reads resumes in 70 languages and accepts PDF, Word, OpenDocument, PowerPoint, text and image files, including scans. Job descriptions can be in any language.
Sources and further reading
- HireLayer API documentation: screening pipeline guide
- HireLayer API documentation: Match
- HireLayer API documentation: Rank
- EU AI Act, Annex III: high-risk AI systems
- NYC Department of Consumer and Worker Protection: Automated Employment Decision Tools
Louis Desclous
Published on · Reading time: 10 minutes
