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

Source: https://onlineresumeparser.com/api-docs/screening-pipeline · Markdown: https://onlineresumeparser.com/api-docs/screening-pipeline.md

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](https://onlineresumeparser.com/api-docs/job-extract.md) 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](https://onlineresumeparser.com/api-docs/extract.md) when a resume arrives and store the JSON. Keep `info_resume.text` for matching, truncated to 50,000 characters.

### 3. Rank the shortlist

[Rank](https://onlineresumeparser.com/api-docs/rank.md) 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](https://onlineresumeparser.com/api-docs/match.md) evaluates a candidate against every criterion. The score ignores `is_mandatory`: check mandatory criteria with a `not_valid` status in your own code.

> **Tip: Normalize skills for search.** Skills from CV Extract are already matched to the catalog when `status` is `normalized`. Send the `raw` ones, or skills typed by users, to [Skills](https://onlineresumeparser.com/api-docs/skills.md) in batches of up to 100.

## Complete program

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.

**Client (hirelayer)**

**Python**

```python
# 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,
    )

```

**TypeScript**

```typescript
// 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
    )
  }
}

```

**Pipeline (screening)**

**Python**

```python
# 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"])

```

**TypeScript**

```typescript
// 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)
  }
}

```

## Production checklist

- 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_id` and Rank `id`s, so results map back to your records.
- Handle `422` from CV Extract as a permanent outcome for that file (not a resume, unreadable, empty).
- Send `do_not_store_data=true` if 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.

## Next

- [Errors and retries](https://onlineresumeparser.com/api-docs/errors.md): Error format, every status code and message, and when to retry a HireLayer API call.
- [Build with AI agents](https://onlineresumeparser.com/api-docs/ai-agents.md): Give Claude Code, Codex, Cursor or any coding agent the context it needs to integrate HireLayer correctly: llms.txt, Markdown pages, OpenAPI and a ready-made rules file.
