# HireLayer Job Criteria Extractor

> HireLayer job description parsing API: send a job ad as text or a file and get its typed, weighted criteria back as JSON, for free.

Source: https://hirelayer.co/api-docs/extract-criteria · Markdown: https://hirelayer.co/api-docs/extract-criteria.md

The HireLayer Job Criteria Extractor reads a job description and returns its criteria in one `POST /api/v4/jobs/extract-criteria` call: usually 4 to 10, each with a type, a requirement, a weight and, when the ad sets one, a threshold such as `3 years minimum` or `B2`. Send the job as text or as a PDF, DOCX, TXT or HTML file. It is free, except when a new text is sent under the `job_id` of a job already scored: its stored scores are evaluated again on the new criteria, 1 credit each. The job is stored under a `job_id`, and the [Resume Scorer](https://hirelayer.co/api-docs/score.md), [Talent Pool Search](https://hirelayer.co/api-docs/search-resumes.md) and [Job Recommendations](https://hirelayer.co/api-docs/search-jobs.md) reuse its criteria.

| Field | Values |
| --- | --- |
| `type` | `skill`, `experience`, `education`, `language`, `certification`, `location`, `soft_skill`, `other` |
| `requirement` | `required`, `preferred`, `bonus`. `required`: a resume that clearly lacks it loses 25 % of its score, never eliminated outright. `preferred` and `bonus` count only through their weight. |
| `weight` | Importance. 3 essential, 2 important, 1 nice to have, 0 off: kept on the job, left out of every score, of the required-criterion penalty, of `required_missing` and of `to_verify`. Texts already written (`summary`, `explanation`) stay as they are. |
| `threshold` | The minimum level the ad asks for, or `null`. |

Criteria are written in the job’s language: send `language` (`en`, `fr`, `de`, `es` or `pt`) to choose it; left out, it is the language of the text, and English for a text in any other language. Only the requirements the text states become criteria, in its order: a short posting gives fewer than the usual 4 to 10, never invented ones. The text holds at least 30 characters. Correct a criterion with [`PATCH /jobs/{job_id}/criteria/{criterion_id}`](https://hirelayer.co/api-docs/jobs.md#update-criterion): a weight, requirement or type change re-ranks the stored scores for free. A job text identical to the stored job returns the job as stored: `title`, `location` and `language` sent with it are ignored; change them with `PUT /jobs/{job_id}`.

- **Endpoint:** `POST /api/v4/jobs/extract-criteria`
- **Input:** JSON `text` (50,000 characters) or a job file
- **Output:** The criteria and the `job_id`
- **Price:** Free; 1 credit per stored score evaluated again when a new text replaces a stored job

## Extract job criteria

`POST https://hirelayer.co/api/v4/jobs/extract-criteria`

Reads a job description and returns the typed criteria it states (usually 4 to 10, fewer for a short posting: none is invented; at most 12), in the posting's order, the job stored under a `job_id`. The text holds at least 30 characters.

- **Authentication:** `X-API-Key` header
- **Content type:** `application/json`, or `multipart/form-data` with files
- **Billing:** Free; 1 credit per stored score evaluated again when a new text replaces a stored job
- **Client timeout:** at least 150 seconds

- **The same text sent again** under the same id (or without an id) returns the stored job and its current criteria (`200`), without a new extraction; the call's `title`, `location` and `language` are then ignored: change them with `PUT /jobs/{job_id}`. A different text under an existing `job_id` replaces it and extracts the criteria again, as `PUT /jobs/{job_id}` with `recompute_criteria: true` would; its `title`, `location` and `status` stay as stored unless the call sends them. To extract again from the same text, use that `PUT`.

### Headers

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `HireLayer-Workspace` | `string` | No | Your customer's workspace id. Case-insensitive, created on first use. Absent, calls use `default`. |

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | `string` | Yes | The full job description, as published. 30–50,000 characters. |
| `title` | `string \| null` | No | ≤ 300 characters. |
| `location` | `string \| null` | No | Where the job is based. Read by the extraction. ≤ 300 characters. |
| `language` | `string` | No | Language of the job's criteria labels and thresholds, score summaries and explanations, and ranking reasons. Sent, it is kept; left out, it is the language of the job's text when the job is created, and `en` when that text is in another language or its language cannot be told. One of: `"en"`, `"fr"`, `"de"`, `"es"`, `"pt"`. |
| `job_id` | `string` | No | Letters, digits and `. _ : ~ -`, up to 200 characters. |
| `do_not_store_data` | `boolean` | No | Enterprise plan only, else `403 enterprise_plan_required`: process without storing anything. See [Storage](https://hirelayer.co/api-docs/authentication.md#storage). Default `false`. |

### Example request

**cURL**

```bash
curl -X POST https://hirelayer.co/api/v4/jobs/extract-criteria \
  -H "X-API-Key: $HIRELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Ingénieur IA / Développeur backend Node.js (H/F)",
    "text": "Ingénieur IA / Développeur backend Node.js (H/F)\nLumio · Paris 9e · CDI · Hybride (2 jours de télétravail par semaine)\n- 3 ans minimum d'\''expérience en développement backend Node.js / TypeScript\n- Expérience indispensable de l'\''intégration de LLM en production\n- Vous maîtrisez AWS (Lambda, S3, DynamoDB) ; une certification AWS est un plus\n- Anglais courant (B2 minimum) obligatoire"
  }'
```

**Python**

```python
import os

import requests

response = requests.post(
    "https://hirelayer.co/api/v4/jobs/extract-criteria",
    headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
    json={
        "title": "Ingénieur IA / Développeur backend Node.js (H/F)",
        "text": "Ingénieur IA / Développeur backend Node.js (H/F)\nLumio · Paris 9e · CDI · Hybride (2 jours de télétravail par semaine)\n- 3 ans minimum d'expérience en développement backend Node.js / TypeScript\n- Expérience indispensable de l'intégration de LLM en production\n- Vous maîtrisez AWS (Lambda, S3, DynamoDB) ; une certification AWS est un plus\n- Anglais courant (B2 minimum) obligatoire",
    },
    timeout=150,
)
response.raise_for_status()

data = response.json()
print(data["criteria"])
```

**TypeScript**

```typescript
const response = await fetch('https://hirelayer.co/api/v4/jobs/extract-criteria', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.HIRELAYER_API_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'Ingénieur IA / Développeur backend Node.js (H/F)',
    text: 'Ingénieur IA / Développeur backend Node.js (H/F)\nLumio · Paris 9e · CDI · Hybride (2 jours de télétravail par semaine)\n- 3 ans minimum d\'expérience en développement backend Node.js / TypeScript\n- Expérience indispensable de l\'intégration de LLM en production\n- Vous maîtrisez AWS (Lambda, S3, DynamoDB) ; une certification AWS est un plus\n- Anglais courant (B2 minimum) obligatoire',
  }),
  signal: AbortSignal.timeout(150_000),
})

if (!response.ok) {
  throw new Error(`HireLayer ${response.status}: ${await response.text()}`)
}

const data = await response.json()
console.log(data.criteria)
```

### Response `200`

A job was already stored under this id. The same text returns its current criteria; another text replaces it and its criteria are extracted again.

| Field | Type | Description |
| --- | --- | --- |
| `request_id` | `string` | The call's id, also in the `HireLayer-Request-Id` header. Quote it when contacting support. |
| `job_id` | `string \| null` | `null` without storage. |
| `stored` | `boolean` |  |
| `title` | `string \| null` |  |
| `location` | `string \| null` |  |
| `language` | `string` | Language of the job's criteria labels and thresholds, score summaries and explanations, and ranking reasons. Sent, it is kept; left out, it is the language of the job's text when the job is created, and `en` when that text is in another language or its language cannot be told. One of: `"en"`, `"fr"`, `"de"`, `"es"`, `"pt"`. |
| `criteria` | `object[]` | Usually 4 to 10, at most 12, numbered `crit_1`, `crit_2`… in order. 1–12 items. |
| `criteria[].criterion_id` | `string` | Stable id in the job, such as `crit_2`. Never reused. |
| `criteria[].label` | `string` |  |
| `criteria[].type` | `string` | One of: `"skill"`, `"experience"`, `"education"`, `"language"`, `"certification"`, `"location"`, `"soft_skill"`, `"other"`. |
| `criteria[].requirement` | `string` | `required`: a resume that clearly lacks it loses 25 % of its score, never eliminated outright. `preferred` and `bonus` count only through their weight. One of: `"required"`, `"preferred"`, `"bonus"`. |
| `criteria[].weight` | `integer` | Importance. 3 essential, 2 important, 1 nice to have, 0 off: kept on the job, left out of every score, of the required-criterion penalty, of `required_missing` and of `to_verify`. Texts already written (`summary`, `explanation`) stay as they are. 0–3. |
| `criteria[].threshold` | `string \| null` |  |
| `criteria[].source` | `string` | `extracted` by HireLayer, or `custom`: sent or edited by you. One of: `"extracted"`, `"custom"`. |
| `credits_used` | `integer` | Credits this call cost. ≥ 0. |

### Response `201`

Job created and criteria extracted.

| Field | Type | Description |
| --- | --- | --- |
| `request_id` | `string` | The call's id, also in the `HireLayer-Request-Id` header. Quote it when contacting support. |
| `job_id` | `string \| null` | `null` without storage. |
| `stored` | `boolean` |  |
| `title` | `string \| null` |  |
| `location` | `string \| null` |  |
| `language` | `string` | Language of the job's criteria labels and thresholds, score summaries and explanations, and ranking reasons. Sent, it is kept; left out, it is the language of the job's text when the job is created, and `en` when that text is in another language or its language cannot be told. One of: `"en"`, `"fr"`, `"de"`, `"es"`, `"pt"`. |
| `criteria` | `object[]` | Usually 4 to 10, at most 12, numbered `crit_1`, `crit_2`… in order. 1–12 items. |
| `criteria[].criterion_id` | `string` | Stable id in the job, such as `crit_2`. Never reused. |
| `criteria[].label` | `string` |  |
| `criteria[].type` | `string` | One of: `"skill"`, `"experience"`, `"education"`, `"language"`, `"certification"`, `"location"`, `"soft_skill"`, `"other"`. |
| `criteria[].requirement` | `string` | `required`: a resume that clearly lacks it loses 25 % of its score, never eliminated outright. `preferred` and `bonus` count only through their weight. One of: `"required"`, `"preferred"`, `"bonus"`. |
| `criteria[].weight` | `integer` | Importance. 3 essential, 2 important, 1 nice to have, 0 off: kept on the job, left out of every score, of the required-criterion penalty, of `required_missing` and of `to_verify`. Texts already written (`summary`, `explanation`) stay as they are. 0–3. |
| `criteria[].threshold` | `string \| null` |  |
| `criteria[].source` | `string` | `extracted` by HireLayer, or `custom`: sent or edited by you. One of: `"extracted"`, `"custom"`. |
| `credits_used` | `integer` | Credits this call cost. ≥ 0. |

```json
{
  "request_id": "0c4a7b1e-5d2f-4e8a-b3c6-9f1d2e7a4b58",
  "job_id": "job_30cfa454c97663f3e1801e5a61b87d74",
  "stored": true,
  "title": "Ingénieur IA / Développeur backend Node.js (H/F)",
  "location": null,
  "language": "fr",
  "criteria": [
    {
      "criterion_id": "crit_1",
      "label": "Développement backend Node.js / TypeScript",
      "type": "skill",
      "requirement": "preferred",
      "weight": 3,
      "threshold": "3 ans minimum",
      "source": "extracted"
    },
    {
      "criterion_id": "crit_2",
      "label": "Intégration de LLM en production",
      "type": "skill",
      "requirement": "required",
      "weight": 3,
      "threshold": null,
      "source": "extracted"
    },
    {
      "criterion_id": "crit_3",
      "label": "AWS (Lambda, S3, DynamoDB)",
      "type": "skill",
      "requirement": "preferred",
      "weight": 2,
      "threshold": null,
      "source": "extracted"
    },
    {
      "criterion_id": "crit_4",
      "label": "Diplôme d'ingénieur ou Bac+5 en informatique",
      "type": "education",
      "requirement": "preferred",
      "weight": 2,
      "threshold": "Bac+5",
      "source": "extracted"
    },
    {
      "criterion_id": "crit_5",
      "label": "Anglais",
      "type": "language",
      "requirement": "required",
      "weight": 2,
      "threshold": "B2 minimum",
      "source": "extracted"
    },
    {
      "criterion_id": "crit_6",
      "label": "Encadrement technique",
      "type": "experience",
      "requirement": "bonus",
      "weight": 1,
      "threshold": null,
      "source": "extracted"
    },
    {
      "criterion_id": "crit_7",
      "label": "Certification AWS",
      "type": "certification",
      "requirement": "bonus",
      "weight": 1,
      "threshold": null,
      "source": "extracted"
    },
    {
      "criterion_id": "crit_8",
      "label": "Astreinte",
      "type": "other",
      "requirement": "required",
      "weight": 1,
      "threshold": "1 semaine sur 6",
      "source": "extracted"
    }
  ],
  "credits_used": 0
}
```

### From a file request

Send the job description as a PDF, DOCX, TXT or HTML file in the multipart field `file`.

**cURL**

```bash
curl -X POST https://hirelayer.co/api/v4/jobs/extract-criteria \
  -H "X-API-Key: $HIRELAYER_API_KEY" \
  -F "file=@job.pdf;type=application/pdf"
```

**Python**

```python
import os

import requests

with open("job.pdf", "rb") as file:
    response = requests.post(
        "https://hirelayer.co/api/v4/jobs/extract-criteria",
        headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
        files={"file": ("job.pdf", file, "application/pdf")},
        timeout=150,
    )
response.raise_for_status()

data = response.json()
print(data["criteria"])
```

**TypeScript**

```typescript
import { readFile } from 'node:fs/promises'

const form = new FormData()
form.append(
  'file',
  new Blob([await readFile('job.pdf')], { type: 'application/pdf' }),
  'job.pdf'
)

const response = await fetch('https://hirelayer.co/api/v4/jobs/extract-criteria', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.HIRELAYER_API_KEY!,
  },
  body: form,
  signal: AbortSignal.timeout(150_000),
})

if (!response.ok) {
  throw new Error(`HireLayer ${response.status}: ${await response.text()}`)
}

const data = await response.json()
console.log(data.criteria)
```

The response is the `201` above.

### Errors

| Status | `error.code` | `error.message` | When | Retry |
| --- | --- | --- | --- | --- |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `text must be 30 characters or more: send the job's full text.` | `param`: `text`. The text (or the text read in `file`) is under 30 characters. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `text or file is required.` | `param`: `text`. Neither `text` nor a file. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `title must be a string.` | `param`: `title`. `text`, `title`, `location`, `language` or `job_id` is not a string. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `language must be one of en, fr, de, es, pt.` | `param`: `language`. `language` is not `en`, `fr`, `de`, `es` or `pt`. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `title must be 300 characters or less.` | `param`: `title`. `title` or `location` over 300 characters. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `job_id cannot be search: that name is reserved for POST /api/v4/jobs/search.` | `param`: `job_id`. `job_id` is not a valid id, or is a name the paths reserve (`extract-criteria`, `search`). | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `Unknown field lang.` | `param`: `lang`. A field the call does not take. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `The request body is not valid JSON.` | The body is not valid JSON, not an object, or not sent as `application/json`. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `HireLayer-Workspace must contain letters, digits and . _ : ~ -, up to 200 characters.` | `param`: `HireLayer-Workspace`. `HireLayer-Workspace` is not a valid id. | Fix, then retry |
| `401` | [`missing_api_key`](https://hirelayer.co/api-docs/errors.md#missing_api_key) | `The X-API-Key header is missing.` | No `X-API-Key` header. | Fix, then retry |
| `401` | [`missing_api_key`](https://hirelayer.co/api-docs/errors.md#missing_api_key) | `Authorization: Bearer is not accepted: send the key in the X-API-Key header.` | The key sent as `Authorization: Bearer`: it goes in `X-API-Key`. | Fix, then retry |
| `401` | [`missing_api_key`](https://hirelayer.co/api-docs/errors.md#missing_api_key) | `The key goes in the X-API-Key header, not in the URL.` | The key sent in the URL: it goes in `X-API-Key`. | Fix, then retry |
| `401` | [`invalid_api_key`](https://hirelayer.co/api-docs/errors.md#invalid_api_key) | `Invalid API key: it is unknown, malformed or revoked.` | The key is unknown, malformed or revoked. | Fix, then retry |
| `403` | [`insufficient_credits`](https://hirelayer.co/api-docs/errors.md#insufficient_credits) | `This call evaluates 12 scores, 1 credit each: 12 credits, and 3 are left. Nothing was processed or billed.` | A new text replaces a stored job already scored, and the credits left do not cover its scores evaluated again. | Fix, then retry |
| `403` | [`enterprise_plan_required`](https://hirelayer.co/api-docs/errors.md#enterprise_plan_required) | `do_not_store_data is available on the Enterprise plan only. Nothing was processed or billed.` | `param`: `do_not_store_data`. `do_not_store_data: true` below the Enterprise plan. | Do not retry |
| `413` | [`payload_too_large`](https://hirelayer.co/api-docs/errors.md#payload_too_large) | `text must be 50,000 characters or less.` | `param`: `text`. The text is over 50,000 characters. | Fix, then retry |
| `413` | [`payload_too_large`](https://hirelayer.co/api-docs/errors.md#payload_too_large) | `file must be 4.5 MB or less.` | `param`: `file`. The file is over 4.5 MB. | Fix, then retry |
| `413` | [`payload_too_large`](https://hirelayer.co/api-docs/errors.md#payload_too_large) | `The text of file must be 50,000 characters or less.` | `param`: `file`. The text read in the file is over 50,000 characters. | Fix, then retry |
| `422` | [`job_unreadable`](https://hirelayer.co/api-docs/errors.md#job_unreadable) | `file has no text layer: it is probably a scan. Send its text instead.` | `param`: `file`. The file cannot be read: a scan, a protected or damaged PDF, a PDF of more than 50 pages, an old `.doc`, or another type. | Do not retry |
| `500` | [`internal_error`](https://hirelayer.co/api-docs/errors.md#internal_error) | `Internal error: retry later. If it persists, contact support with the request_id.` | An unexpected error. | Retry with backoff |
| `502` | [`model_unavailable`](https://hirelayer.co/api-docs/errors.md#model_unavailable) | `The analysis did not complete after several attempts. Nothing was stored or billed: retry with backoff.` | The analysis did not complete. | Retry with backoff |
| `502` | [`internal_error`](https://hirelayer.co/api-docs/errors.md#internal_error) | `The service could not be reached: retry later.` | The service could not be reached. | Retry with backoff |
| `503` | [`internal_error`](https://hirelayer.co/api-docs/errors.md#internal_error) | `The credit service is briefly unavailable: retry in a second.` | The credit service is briefly unavailable. Headers: `Retry-After: <seconds>`. | Retry with backoff |
| `504` | [`internal_error`](https://hirelayer.co/api-docs/errors.md#internal_error) | `The service did not answer in time. The change may still be applied: read it before sending it again.` | No answer in time. | Retry with backoff |

Example error (`400`):

```json
{
  "error": {
    "code": "invalid_request",
    "message": "text must be 30 characters or more: send the job's full text.",
    "param": "text",
    "doc_url": "https://hirelayer.co/api-docs/errors#invalid_request"
  },
  "request_id": "6f1c2a4e-1d2b-4c3a-9e8f-0a1b2c3d4e5f"
}
```

### Behavior

- **Language:** `language` (`en`, `fr`, `de`, `es` or `pt`) sets the language of the criteria labels and thresholds, and of the summaries and explanations of the job's scores. Left out, it is the language of the job's text, and `en` for a text in any other language. The answer's `language` says which one was used.
- **Credits:** 0. Except when a different text replaces a stored job that has scores and the extraction brings new criteria: as with `PUT /jobs/{job_id}`, its stored scores are evaluated again in the background, 1 credit each, and the call is refused (`403`) when the credits left do not cover them. `credits_used` counts this call's extraction only.
- **Without storage:** `job_id` is `null`. Keep the criteria and send them back with the job to `POST /resumes/score`, so that scores stay comparable from one call to the next.

## Next

- [HireLayer Resume Scorer](https://hirelayer.co/api-docs/score.md): HireLayer candidate matching and ranking API: score one or many resumes against a job, criterion by criterion, with evidence, a verdict and an optional precise ranking.
- [HireLayer Resume Parser](https://hirelayer.co/api-docs/parse-resume.md): HireLayer resume parser API reference: send a PDF, Word or image resume in one multipart request and get the candidate back as JSON, photo included, in 70 languages.
