Guides
Build a screening pipeline
Chain HireLayer CV Extract, Job Extract, Rank and Match to parse resumes, score them against a job and explain the result.
Each API works on its own. Together they cover a screening workflow: structure the job once, parse every resume, shortlist, then explain.
- 1
Extract the job criteria once
Call Job Extract when a job is created or its description changes. Let a recruiter review the criteria, then store them with the job: Match takes them as they are.
- 2
Parse each resume
Call CV Extract when a resume arrives and store the JSON. Keep
info_resume.textfor matching, truncated to 50,000 characters. - 3
Rank the shortlist
Rank orders up to 10 candidates for one job in a single call. For larger pools, pre-filter first, or score each candidate with Match.
- 4
Explain with Match
Match evaluates a candidate against every criterion. The score ignores
is_mandatory: check mandatory criteria with anot_validstatus in your own code.
Save the client as hirelayer.py or hirelayer.mts, put the job description in job.txt, then run python screening.py or npx tsx screening.mts. Credits used: 1 for the criteria, 1 per resume, 1 for the ranking and 1 for the explanation.
# hirelayer.py — minimal client with retries. Requires: pip install requests
import mimetypes
import os
import random
import time
import requests
API_BASE = "https://onlineresumeparser.com/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,
)
// hirelayer.mts — minimal client with retries. Node.js 18+, run with tsx.
const API_BASE = 'https://onlineresumeparser.com/api'
export class HireLayerError extends Error {
status: number
code?: string
requestId?: string
constructor(status: number, message: string, code?: string, requestId?: string) {
super(`HireLayer ${status}: ${message}`)
this.status = status
this.code = code
this.requestId = requestId
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
function retryDelay(attempt: number, retryAfter: string | null) {
const seconds = Number(retryAfter)
if (retryAfter && Number.isInteger(seconds)) return seconds * 1000
return Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000
}
/** Calls HireLayer. Retries 5xx responses, throws HireLayerError otherwise. */
export async function callHireLayer<T>(
path: string,
options: { json?: unknown; form?: FormData; timeoutMs?: number } = {},
maxRetries = 3
): Promise<T> {
const headers: Record<string, string> = {
'X-API-Key': process.env.HIRELAYER_API_KEY!,
}
let body: string | FormData | undefined = options.form
if (options.json !== undefined) {
headers['Content-Type'] = 'application/json'
body = JSON.stringify(options.json)
}
for (let attempt = 0; ; attempt++) {
const response = await fetch(API_BASE + path, {
method: body === undefined ? 'GET' : 'POST',
headers,
body,
signal: AbortSignal.timeout(options.timeoutMs ?? 65_000),
})
if (response.ok) return (await response.json()) as T
if (response.status >= 500 && attempt < maxRetries) {
await sleep(retryDelay(attempt, response.headers.get('Retry-After')))
continue
}
const payload = await response.json().catch(() => ({}))
throw new HireLayerError(
response.status,
payload.error ?? response.statusText,
payload.code,
response.headers.get('x-parser-request-id') ?? undefined
)
}
}
# screening.py — parse resumes, extract criteria, rank, then explain the top match.
from hirelayer import call, parse_resume
MAX_TEXT = 50_000 # Match and Rank limit; resume text can reach 100,000 characters
with open("job.txt", encoding="utf-8") as file:
job_text = file.read()
# 1. Extract criteria once per job, then store them with the job.
criteria = call("POST", "/v1/jobs/extract-criteria", json={"job_text": job_text})[
"matching_criteria"
]
# 2. Parse each resume (synchronous, about 35 seconds each).
resumes = {}
for path in ["alex.pdf", "sam.pdf", "charlie.pdf"]:
resume = parse_resume(path, application_id=path)
resumes[path] = resume["info_resume"]["text"][:MAX_TEXT]
# 3. Rank up to 10 candidates in one call.
rankings = call(
"POST",
"/v1/matching/job-candidates/rank",
json={
"job_text": job_text,
"candidates": [
{"id": key, "candidate_text": text} for key, text in resumes.items()
],
},
)["rankings"]
# 4. Explain the best candidate, criterion by criterion.
best = rankings[0]["candidate_id"]
match = call(
"POST",
"/v1/matching/job-candidate",
json={
"job_text": job_text,
"candidate_text": resumes[best],
"matching_criteria": criteria,
},
)
print(best, round(match["score"], 2), match["summary"])
for criterion in match["evaluated_criteria"]:
if criterion["is_mandatory"] and criterion["match_status"] == "not_valid":
print("Missing mandatory criterion:", criterion["label"])
// screening.mts — parse resumes, extract criteria, rank, then explain the top match.
// Run: npx tsx screening.mts
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { callHireLayer } from './hirelayer.mts'
type Criterion = {
id: string
label: string
weight: number
is_mandatory: boolean
rationale: string
}
type ParsedResume = { info_resume: { text: string } }
type Ranking = { rank: number; candidate_id: string; score: number }
type MatchResult = {
score: number
summary: string
evaluated_criteria: Array<Criterion & { match_status: string }>
}
const MAX_TEXT = 50_000 // Match and Rank limit; resume text can reach 100,000 characters
const jobText = await readFile('job.txt', 'utf8')
// 1. Extract criteria once per job, then store them with the job.
const { matching_criteria } = await callHireLayer<{
matching_criteria: Criterion[]
}>('/v1/jobs/extract-criteria', { json: { job_text: jobText } })
// 2. Parse each resume (synchronous, about 35 seconds each).
const resumes = new Map<string, string>()
for (const path of ['alex.pdf', 'sam.pdf', 'charlie.pdf']) {
const form = new FormData()
form.append('file', new Blob([await readFile(path)]), basename(path))
form.append('application_id', path)
const resume = await callHireLayer<ParsedResume>('/v3/parser', {
form,
timeoutMs: 150_000,
})
resumes.set(path, resume.info_resume.text.slice(0, MAX_TEXT))
}
// 3. Rank up to 10 candidates in one call.
const { rankings } = await callHireLayer<{ rankings: Ranking[] }>(
'/v1/matching/job-candidates/rank',
{
json: {
job_text: jobText,
candidates: [...resumes].map(([id, candidate_text]) => ({
id,
candidate_text,
})),
},
}
)
// 4. Explain the best candidate, criterion by criterion.
const best = rankings[0].candidate_id
const match = await callHireLayer<MatchResult>('/v1/matching/job-candidate', {
json: {
job_text: jobText,
candidate_text: resumes.get(best),
matching_criteria,
},
})
console.log(best, match.score.toFixed(2), match.summary)
for (const criterion of match.evaluated_criteria) {
if (criterion.is_mandatory && criterion.match_status === 'not_valid') {
console.log('Missing mandatory criterion:', criterion.label)
}
}
- Call HireLayer from a background job, not from a user-facing request: parsing takes about 35 seconds.
- Store results: criteria with the job, parsed JSON with the candidate. Re-running a call costs a credit and can return slightly different text.
- Use your own IDs as
application_idand Rankids, so results map back to your records. - Handle
422from CV Extract as a permanent outcome for that file (not a resume, unreadable, empty). - Send
do_not_store_data=trueif you keep your own copy of the files: HireLayer then does not store the resume file. - Keep a human in the loop: scores and rankings support a recruiter, they do not replace one.