# HireLayer Resume Scorer

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

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

The HireLayer Resume Scorer evaluates resumes against a job, criterion by criterion, and returns them best first in one `POST /api/v4/resumes/score` call. The job comes as a file, a text or a stored `job_id`; each resume as a file, a text, a parsed JSON or a stored `application_id`, mixed freely, up to 100 per call. Each resume gets a score from 0 to 1, a verdict, a summary and, for every criterion, a status, the evidence quoted from the resume and an explanation.

- With files, `precise_ranking`, `webhook_url`, `do_not_store_data` and every other field go inside the `request` JSON, never as form fields: any part but `request` and the files is refused (`400`).
- Ranking: `precise_ranking: true` compares the 10 best resumes of the call against each other and adds a `ranking_reason` to each, for 1 credit more per call.
- Price: 1 credit per resume scored, +1 per resume file parsed. A pair already scored is free to ask again, so a recruiter can reload the ranking as often as needed. A stored job sent again with a new text or new criteria also evaluates its other stored scores again, 1 credit each; when the credits left do not cover them, the call is refused (`403`) before the job is changed.
- 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}`.
- Time: about a minute for 50 resumes. Past 10 files or 50 resumes, the call answers `202` and the scores are read at `results_url` or posted to a [webhook](https://hirelayer.co/api-docs/webhooks.md). With storage, a smaller call whose evaluations still run after about 100 seconds answers `202` too.

## How the score is computed

The score is the weighted mean of the criterion scores (`met` 1, `partial` 0.67, `missing` 0 or 0.33 with a weak hint), criteria marked `unknown` left out, then × 0.75 for each required criterion clearly missing. A criterion of weight 0 is off. The verdict is an overall reading of the resume for the job:

| `verdict` | Meaning |
| --- | --- |
| `highly_relevant` | Shortlist. |
| `relevant` | Worth a call. |
| `marginally_relevant` | Weak. |
| `not_relevant` | Off-topic. |

- **Endpoint:** `POST /api/v4/resumes/score`
- **Input:** A job (file, text or `job_id`) and 1 to 100 resumes (files, texts, parsed JSON or `application_id`s)
- **Output:** One score per resume, best first
- **Price:** 1 credit per resume evaluated, +1 per resume file parsed, +1 per call with precise_ranking; 0 for a pair already evaluated or a file already stored; 1 credit per other stored score evaluated again when a stored job comes with a new text or new criteria

## Score resumes against a job

`POST https://hirelayer.co/api/v4/resumes/score`

Evaluates one or more resumes against a job, criterion by criterion, and returns them best first.

- **Authentication:** `X-API-Key` header
- **Content type:** `application/json`, or `multipart/form-data` with files
- **Billing:** 1 credit per resume evaluated, +1 per resume file parsed, +1 per call with precise_ranking; 0 for a pair already evaluated or a file already stored; 1 credit per other stored score evaluated again when a stored job comes with a new text or new criteria
- **Client timeout:** at least 150 seconds

**Synchronous or not.**

| The call | The answer |
| --- | --- |
| No `webhook_url`, at most 10 resume files and at most 50 resumes in all | `200` with every score: about a minute for 50 resumes (measured 60 to 64 seconds), less for fewer, plus the parse of any file; set a client timeout of 150 seconds. With storage, evaluations still running after about 100 seconds answer `202` instead: poll `results_url` |
| No `webhook_url`, more than that, with storage | `202`; poll `results_url` until no score is `pending` |
| No `webhook_url`, more than that, without storage | Refused: `400 invalid_request`, `param: webhook_url` |
| With `webhook_url` | `202`; the full result is posted to the URL, and with storage it can also be read at `results_url` |

### 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 |
| --- | --- | --- | --- |
| `job` | `object` | Yes | Exactly one of `file`, `text` or `job_id`. `criteria` goes only with `file` or `text`; criteria alone are refused. |
| `job.file` | `string` | No | Name of the multipart part that holds the job description file (PDF, DOCX, TXT or HTML, 4.5 MB at most). A PDF of more than 50 pages, or one without a text layer, cannot be read (`422 job_unreadable`). |
| `job.job_id` | `string` | No | Alone: the id of a stored one. With `file`, `text`: your own id to store it under. |
| `job.criteria` | `object[]` | No | The job's own criteria; they replace the extraction. 1–20 items. |
| `job.criteria[].label` | `string` | Yes | 1–300 characters. |
| `job.criteria[].type` | `string` | No | One of: `"skill"`, `"experience"`, `"education"`, `"language"`, `"certification"`, `"location"`, `"soft_skill"`, `"other"`. |
| `job.criteria[].requirement` | `string` | No | `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"`. |
| `job.criteria[].weight` | `integer` | No | 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. |
| `job.criteria[].threshold` | `string \| null` | No | Minimum level in short text, such as `3 ans minimum`, `Bac+2` or `B2`. ≤ 100 characters. |
| `job.criteria[].criterion_id` | `string` | No |  |
| `job.criteria[].source` | `string` | No |  |
| `job.title` | `string` | No | ≤ 300 characters. |
| `job.location` | `string` | No | ≤ 300 characters. |
| `job.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.text` | `string` | No | The job description. 30–50,000 characters. |
| `resumes` | `object[]` | Yes | 1–100 items. |
| `resumes[].file` | `string` | No | Name of the multipart part that holds the resume file. |
| `resumes[].application_id` | `string` | No | Alone: the id of a stored one. With `file`, `text`, `resume`: your own id to store it under. |
| `resumes[].reference` | `string` | No | Your label, returned as is. ≤ 200 characters. |
| `resumes[].text` | `string` | No | The resume as plain text. 1–50,000 characters. |
| `resumes[].resume` | `object` | No | The parsed resume JSON, as `POST /resumes/parse` returns it in `resume`, or the whole response of the legacy `/api/v3/parser`. 1 MB at most (`413`), arrays and objects nested 100 levels deep at most (`400`). It is not parsed again: no parsing credit. |
| `precise_ranking` | `boolean` | No | Re-rank the 10 best resumes of this call against each other and add `ranking_reason`. 1 credit more for the call, when it has at least 2 resumes evaluated to rank. Default `false`. |
| `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`. |
| `webhook_url` | `string (URL)` | No | HTTPS URL on the standard port, without credentials, that receives the result, signed. |

### Example request

**cURL**

```bash
# Example ids: replace job_30cfa454c97663f3e1801e5a61b87d74 with a job_id returned by POST /api/v4/jobs/extract-criteria, and file_2c982f413a5e9e79563f8a1fcb5c7caa, file_addd6bc79e818deebe289d009058d6b1 with application_ids returned by POST /api/v4/resumes/parse.
curl -X POST https://hirelayer.co/api/v4/resumes/score \
  -H "X-API-Key: $HIRELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "job": {
      "job_id": "job_30cfa454c97663f3e1801e5a61b87d74"
    },
    "resumes": [
      {
        "application_id": "file_2c982f413a5e9e79563f8a1fcb5c7caa"
      },
      {
        "application_id": "file_addd6bc79e818deebe289d009058d6b1"
      }
    ],
    "precise_ranking": true
  }'
```

**Python**

```python
# Example ids: replace job_30cfa454c97663f3e1801e5a61b87d74 with a job_id returned by POST /api/v4/jobs/extract-criteria, and file_2c982f413a5e9e79563f8a1fcb5c7caa, file_addd6bc79e818deebe289d009058d6b1 with application_ids returned by POST /api/v4/resumes/parse.
import os

import requests

response = requests.post(
    "https://hirelayer.co/api/v4/resumes/score",
    headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
    json={
        "job": {
            "job_id": "job_30cfa454c97663f3e1801e5a61b87d74",
        },
        "resumes": [
            {
                "application_id": "file_2c982f413a5e9e79563f8a1fcb5c7caa",
            },
            {
                "application_id": "file_addd6bc79e818deebe289d009058d6b1",
            },
        ],
        "precise_ranking": True,
    },
    timeout=150,
)
response.raise_for_status()

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

**TypeScript**

```typescript
// Example ids: replace job_30cfa454c97663f3e1801e5a61b87d74 with a job_id returned by POST /api/v4/jobs/extract-criteria, and file_2c982f413a5e9e79563f8a1fcb5c7caa, file_addd6bc79e818deebe289d009058d6b1 with application_ids returned by POST /api/v4/resumes/parse.
const response = await fetch('https://hirelayer.co/api/v4/resumes/score', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.HIRELAYER_API_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    job: {
      job_id: 'job_30cfa454c97663f3e1801e5a61b87d74',
    },
    resumes: [
      {
        application_id: 'file_2c982f413a5e9e79563f8a1fcb5c7caa',
      },
      {
        application_id: 'file_addd6bc79e818deebe289d009058d6b1',
      },
    ],
    precise_ranking: true,
  }),
  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.scores)
```

### Response `200`

Every resume evaluated (or failed), best score first.

| Field | Type | Description |
| --- | --- | --- |
| `request_id` | `string` | Quote it when contacting support. |
| `job` | `object` |  |
| `job.job_id` | `string \| null` | `null` without storage. |
| `job.stored` | `boolean` |  |
| `job.title` | `string \| null` |  |
| `job.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"`. |
| `job.criteria` | `object[]` |  |
| `job.criteria[].criterion_id` | `string` | Stable id in the job, such as `crit_2`. Never reused. |
| `job.criteria[].label` | `string` |  |
| `job.criteria[].type` | `string` | One of: `"skill"`, `"experience"`, `"education"`, `"language"`, `"certification"`, `"location"`, `"soft_skill"`, `"other"`. |
| `job.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"`. |
| `job.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. |
| `job.criteria[].threshold` | `string \| null` |  |
| `job.criteria[].source` | `string` | `extracted` by HireLayer, or `custom`: sent or edited by you. One of: `"extracted"`, `"custom"`. |
| `precise_ranking` | `boolean` |  |
| `scores` | `object[]` |  |
| `scores[].rank` | `integer` | Place in the whole ranking, across pages: by score, except among the 10 best when `precise_ranking` is on. Pending and failed resumes come last. ≥ 1. |
| `scores[].application_id` | `string \| null` | `null` without storage, and for a resume file that could not be read. |
| `scores[].reference` | `string \| null` | The `reference` the resume was sent with. Given back in the answer of `POST /resumes/score`, its webhook and its `results_url`; `null` in a job's stored ranking and in one stored score, which outlive the call. |
| `scores[].file_name` | `string \| null` | The name of the resume file sent to `POST /resumes/score`, given back as `reference` is. |
| `scores[].evaluation_status` | `string` | One of: `"completed"`, `"pending"`, `"failed"`. |
| `scores[].stale` | `boolean` | The resume was replaced after this evaluation. The result stays readable, and is evaluated again the next time the resume is sent to `POST /resumes/score` for this job. |
| `scores[].score` | `number \| null` | Rounded to 2 decimals; `null` until evaluated. 0–1. |
| `scores[].verdict` | `string \| null` | May be absent. The overall judgment of the resume for the job: `highly_relevant` (shortlist), `relevant` (worth a call), `marginally_relevant` (weak), `not_relevant` (off-topic). Present once the evaluation is `completed`, absent while `pending` or `failed`. It does not follow the weights of a read, so it can disagree with `score` after a weight change. `null` when a criterion of the job was added, removed or reworded (`label` or `threshold`) since the last full evaluation of the resume, or when no overall judgment could be given. A change of weight, requirement or type leaves it as is. After a reset, it comes back when the resume is sent again to `POST /resumes/score` for this job: a full evaluation, 1 credit, on top of the credit the new criterion's own evaluation cost. One of: `"highly_relevant"`, `"relevant"`, `"marginally_relevant"`, `"not_relevant"`, `null`. |
| `scores[].summary` | `string \| null` | May be absent. Overall assessment, in the job's language. Present once the evaluation is `completed`. It follows `verdict`, written with it: `null` when `verdict` is `null` (a criterion of the job added, removed or reworded since the last full evaluation of the resume), back with the next full evaluation. |
| `scores[].ranking_reason` | `string` | May be absent. One sentence that places the resume against the others. Only with `precise_ranking`, for ranks 1 to 10, when at least 2 resumes are compared; absent otherwise. |
| `scores[].required_missing` | `object[]` | Required criteria marked `missing` with medium or high confidence; each multiplies the score by 0.75. |
| `scores[].required_missing[].criterion_id` | `string` |  |
| `scores[].required_missing[].label` | `string` |  |
| `scores[].to_verify` | `object[]` | Required criteria not clearly met, to check with the candidate; those of `required_missing` included. |
| `scores[].to_verify[].criterion_id` | `string` |  |
| `scores[].to_verify[].label` | `string` |  |
| `scores[].criteria` | `object[]` | May be absent. Per-criterion detail, in the job's criteria order, once the evaluation is `completed`. Always in `POST /resumes/score` answers; in `GET /jobs/{job_id}/scores`, only with `include=criteria`. |
| `scores[].criteria[].criterion_id` | `string` |  |
| `scores[].criteria[].label` | `string` |  |
| `scores[].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"`. |
| `scores[].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. |
| `scores[].criteria[].status` | `string` | The reading of the criterion. `met`; `partial`; `missing`, which can still carry a weak hint (score 0.33); `unknown`: the resume says nothing about something resumes often leave out, such as availability, and the criterion is left out of the score. One of: `"met"`, `"partial"`, `"missing"`, `"unknown"`. |
| `scores[].criteria[].score` | `number \| null` | 1 met or exceeded, 0.67 partly met, 0.33 a weak hint, 0 absent, `null` unknown. The resume's score uses the exact values 2/3 and 1/3, then rounds to 2 decimals. One of: `0`, `0.33`, `0.67`, `1`, `null`. |
| `scores[].criteria[].gap` | `string \| null` | Why the evidence is partial (scores 0.67 and 0.33): the same thing, less of it; a neighbouring skill of the same trade; plausible but not shown. One of: `"below_threshold"`, `"related"`, `"indirect"`, `null`. |
| `scores[].criteria[].confidence` | `string` | A required criterion missing with `low` confidence does not lower the score. One of: `"high"`, `"medium"`, `"low"`. |
| `scores[].criteria[].evidence` | `string` | Where the resume shows it: the section, then the passages quoted between quotation marks, in the resume's language. Empty when there is none. Free text, so search the quoted passages rather than the whole string. |
| `scores[].criteria[].explanation` | `string` | One sentence, in the job's language. |
| `scores[].error` | `object \| null` | Why this resume failed, when `evaluation_status` is `failed`. It was not billed. |
| `scores[].error.code` | `string` | Stable codes: branch on them, never on `message`. `method_not_allowed` answers a method a path does not take (`405`, with an `Allow` header), outside any operation. One of: `"invalid_request"`, `"storage_required"`, `"missing_api_key"`, `"invalid_api_key"`, `"insufficient_credits"`, `"enterprise_plan_required"`, `"not_found"`, `"payload_too_large"`, `"resume_unreadable"`, `"job_unreadable"`, `"rate_limited"`, `"model_unavailable"`, `"internal_error"`, `"method_not_allowed"`. |
| `scores[].error.message` | `string` | Readable explanation, for logs. |
| `scores[].error.param` | `string` | May be absent. The field or parameter at fault, when there is one, such as `job.criteria` or `resumes[2].file`. |
| `scores[].error.doc_url` | `string (URL)` |  |
| `scores[].evaluated_at` | `string (date-time) \| null` |  |
| `credits_used` | `integer` | Credits this call cost. ≥ 0. |

```json
{
  "request_id": "5d0f3c2e-8a41-4b6e-9f27-1c3b7a9e4d10",
  "job": {
    "job_id": "job_30cfa454c97663f3e1801e5a61b87d74",
    "stored": true,
    "title": "Ingénieur IA / Développeur backend Node.js (H/F)",
    "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"
      }
    ]
  },
  "precise_ranking": true,
  "scores": [
    {
      "rank": 1,
      "application_id": "file_2c982f413a5e9e79563f8a1fcb5c7caa",
      "reference": "cand-paul",
      "file_name": "cv-paul-verse.pdf",
      "evaluation_status": "completed",
      "stale": false,
      "score": 0.9,
      "verdict": "highly_relevant",
      "summary": "Candidat très pertinent avec une expertise forte en LLM et AWS, un excellent niveau d'anglais, à présélectionner malgré une expérience en Node.js légèrement sous le seuil.",
      "ranking_reason": "Le seul des deux à avoir intégré des LLM en production ; son expérience Node.js reste un peu sous le seuil.",
      "required_missing": [],
      "to_verify": [
        {
          "criterion_id": "crit_8",
          "label": "Astreinte"
        }
      ],
      "criteria": [
        {
          "criterion_id": "crit_1",
          "label": "Développement backend Node.js / TypeScript",
          "requirement": "preferred",
          "weight": 3,
          "status": "partial",
          "score": 0.67,
          "gap": "below_threshold",
          "confidence": "medium",
          "evidence": "Ingénieur IA - Novalys AI (2024-25) : 'Stack API (serverless AWS) : API Gateway, Lambda, S3, DynamoDB, Terraform, Node.js' ; Data Scientist - Allobrain (2023-24) : 'Amélioration de compétences en Python et JavaScript'",
          "explanation": "Environ 2 ans d'expérience en Node.js clairement identifiés (Novalys AI), en dessous du seuil de 3 ans demandé, avec une expérience complémentaire en Python/JavaScript chez Allobrain."
        },
        {
          "criterion_id": "crit_2",
          "label": "Intégration de LLM en production",
          "requirement": "required",
          "weight": 3,
          "status": "met",
          "score": 1,
          "gap": null,
          "confidence": "high",
          "evidence": "Ingénieur IA - Novalys AI : 'Développement d’une plateforme SaaS intégrant plusieurs fonctionnalités basées sur l’IA générative', 'orchestration des modèles', 'Solution scalable, 500k+ documents traités' ; Data Scientist - Allobrain : 'focus sur le NLP, les LLM'",
          "explanation": "Le candidat démontre une intégration de LLM en production à grande échelle (500k+ documents, plateforme SaaS) qui dépasse l'exigence."
        },
        {
          "criterion_id": "crit_3",
          "label": "AWS (Lambda, S3, DynamoDB)",
          "requirement": "preferred",
          "weight": 2,
          "status": "met",
          "score": 1,
          "gap": null,
          "confidence": "high",
          "evidence": "Ingénieur IA - Novalys AI : 'Stack API (serverless AWS) : API Gateway, Lambda, S3, DynamoDB, Terraform'",
          "explanation": "Les services AWS Lambda, S3 et DynamoDB sont explicitement utilisés dans un contexte professionnel récent."
        },
        {
          "criterion_id": "crit_4",
          "label": "Diplôme d'ingénieur ou Bac+5 en informatique",
          "requirement": "preferred",
          "weight": 2,
          "status": "met",
          "score": 1,
          "gap": null,
          "confidence": "high",
          "evidence": "EFREI - INGÉNIEUR - BIG DATA & IA : 'Master 2 en Ecole d’ingénieur du numérique'",
          "explanation": "Le candidat possède un Master 2 en école d'ingénieur, conforme à l'exigence de diplôme."
        },
        {
          "criterion_id": "crit_5",
          "label": "Anglais",
          "requirement": "required",
          "weight": 2,
          "status": "met",
          "score": 1,
          "gap": null,
          "confidence": "high",
          "evidence": "Anglais : TOEIC 980/990 ; EFREI : 'cours intégralement en Anglais'",
          "explanation": "Le score TOEIC de 980/990 et la formation en anglais attestent d'un niveau bien supérieur au B2 requis."
        },
        {
          "criterion_id": "crit_6",
          "label": "Encadrement technique",
          "requirement": "bonus",
          "weight": 1,
          "status": "partial",
          "score": 0.67,
          "gap": "related",
          "confidence": "medium",
          "evidence": "COP1 - DIRECTEUR DU RÉSEAU : 'Gouvernance et direction bénévole', '25 salariés', 'gestion des bénévoles : de 3 000 à 5 000 membres'",
          "explanation": "Expérience significative en gestion d'équipe et de salariés (COP1), mais dans un contexte associatif/bénévole plutôt que technique."
        },
        {
          "criterion_id": "crit_7",
          "label": "Certification AWS",
          "requirement": "bonus",
          "weight": 1,
          "status": "met",
          "score": 1,
          "gap": null,
          "confidence": "high",
          "evidence": "AWS - CLOUD PRACTITIONER",
          "explanation": "Le candidat détient la certification AWS Cloud Practitioner, ce qui correspond au critère 'un plus'."
        },
        {
          "criterion_id": "crit_8",
          "label": "Astreinte",
          "requirement": "required",
          "weight": 1,
          "status": "unknown",
          "score": null,
          "gap": null,
          "confidence": "low",
          "evidence": "",
          "explanation": "L'acceptation de l'astreinte n'est pas mentionnée sur le CV."
        }
      ],
      "error": null,
      "evaluated_at": "2026-10-26T22:39:25Z"
    },
    {
      "rank": 2,
      "application_id": "file_addd6bc79e818deebe289d009058d6b1",
      "reference": "cand-rafael",
      "file_name": "cv-rafael-costa.pdf",
      "evaluation_status": "completed",
      "stale": false,
      "score": 0.25,
      "verdict": "marginally_relevant",
      "summary": "Candidat exclusivement frontend (React/TypeScript) sans expérience backend Node.js ni intégration de LLM, deux critères essentiels non satisfaits.",
      "ranking_reason": "Profil frontend sans LLM ni AWS : loin derrière le premier sur les deux critères centraux.",
      "required_missing": [
        {
          "criterion_id": "crit_2",
          "label": "Intégration de LLM en production"
        }
      ],
      "to_verify": [
        {
          "criterion_id": "crit_2",
          "label": "Intégration de LLM en production"
        },
        {
          "criterion_id": "crit_8",
          "label": "Astreinte"
        }
      ],
      "criteria": [
        {
          "criterion_id": "crit_1",
          "label": "Développement backend Node.js / TypeScript",
          "requirement": "preferred",
          "weight": 3,
          "status": "missing",
          "score": 0.33,
          "gap": "related",
          "confidence": "high",
          "evidence": "\"Frontend engineer with 7+ years building React and TypeScript products\"",
          "explanation": "Le candidat a 7 ans d'expérience en TypeScript, mais exclusivement en frontend React ; Node.js est seulement listé dans la tech stack sans expérience associée, ce qui est insuffisant pour un poste backend."
        },
        {
          "criterion_id": "crit_2",
          "label": "Intégration de LLM en production",
          "requirement": "required",
          "weight": 3,
          "status": "missing",
          "score": 0,
          "gap": null,
          "confidence": "high",
          "evidence": "",
          "explanation": "Aucune mention d'intégration de LLM, de RAG ou d'IA dans le CV ; ce critère obligatoire n'est pas satisfait."
        },
        {
          "criterion_id": "crit_3",
          "label": "AWS (Lambda, S3, DynamoDB)",
          "requirement": "preferred",
          "weight": 2,
          "status": "missing",
          "score": 0,
          "gap": null,
          "confidence": "high",
          "evidence": "",
          "explanation": "Aucune mention d'AWS, Lambda, S3 ou DynamoDB ; le candidat évolue sur un périmètre frontend sans exposition à l'infrastructure cloud."
        },
        {
          "criterion_id": "crit_4",
          "label": "Diplôme d'ingénieur ou Bac+5 en informatique",
          "requirement": "preferred",
          "weight": 2,
          "status": "partial",
          "score": 0.67,
          "gap": "below_threshold",
          "confidence": "medium",
          "evidence": "\"Bacharelado em Ciência da Computação (Bachelor of Computer Science)\"",
          "explanation": "Le candidat possède un Bachelor en informatique (4 ans au Brésil), niveau Bac+4 estimé, en dessous du Bac+5 requis."
        },
        {
          "criterion_id": "crit_5",
          "label": "Anglais",
          "requirement": "required",
          "weight": 2,
          "status": "met",
          "score": 1,
          "gap": null,
          "confidence": "high",
          "evidence": "\"English (C1)\"",
          "explanation": "Niveau C1 en anglais, supérieur au seuil B2 obligatoire, avec 5 ans d'expérience en Irlande."
        },
        {
          "criterion_id": "crit_6",
          "label": "Encadrement technique",
          "requirement": "bonus",
          "weight": 1,
          "status": "missing",
          "score": 0.33,
          "gap": "indirect",
          "confidence": "medium",
          "evidence": "\"Co-own the design system (Storybook, Tailwind CSS, Radix UI) with two designers\"",
          "explanation": "Pas d'encadrement formel d'équipe technique ; seule une co-responsabilité sur un design system avec des designers est mentionnée."
        },
        {
          "criterion_id": "crit_7",
          "label": "Certification AWS",
          "requirement": "bonus",
          "weight": 1,
          "status": "missing",
          "score": 0,
          "gap": null,
          "confidence": "high",
          "evidence": "",
          "explanation": "Aucune certification AWS ou autre certification technique mentionnée."
        },
        {
          "criterion_id": "crit_8",
          "label": "Astreinte",
          "requirement": "required",
          "weight": 1,
          "status": "unknown",
          "score": null,
          "gap": null,
          "confidence": "low",
          "evidence": "",
          "explanation": "L'acceptation de l'astreinte n'est pas renseignée sur le CV."
        }
      ],
      "error": null,
      "evaluated_at": "2026-10-26T22:40:51Z"
    }
  ],
  "credits_used": 4
}
```

### Response `202`

Accepted, with `webhook_url`, beyond the synchronous limit, or (with storage) when the evaluations outlast about 100 seconds. The result is posted to `webhook_url` when there is one and, with storage, readable at `results_url`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | `string` | Value: `"pending"`. |
| `request_id` | `string` | Identifies this call in `results_url` and in the webhook. |
| `job_id` | `string \| null` |  |
| `resumes_count` | `integer` | ≥ 1. |
| `webhook_url` | `string (URL) \| null` |  |
| `results_url` | `string (URL) \| null` | With storage: `GET /jobs/{job_id}/scores?request_id=…`, the resumes of this call only. `null` without storage. |

### With files request

Resume files and a job text in one call: the JSON goes in the `request` part, and each file in the part its `file` names. Each file is parsed and stored, +1 credit.

**cURL**

```bash
curl -X POST https://hirelayer.co/api/v4/resumes/score \
  -H "X-API-Key: $HIRELAYER_API_KEY" \
  --form-string 'request={"job":{"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"},"resumes":[{"file":"cv1","reference":"cand-paul"},{"file":"cv2","reference":"cand-rafael"}],"precise_ranking":true}' \
  -F "cv1=@cv-paul-verse.pdf;type=application/pdf" \
  -F "cv2=@cv-rafael-costa.pdf;type=application/pdf"
```

**Python**

```python
import json
import os

import requests

with open("cv-paul-verse.pdf", "rb") as file1, open("cv-rafael-costa.pdf", "rb") as file2:
    response = requests.post(
        "https://hirelayer.co/api/v4/resumes/score",
        headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
        files={"cv1": ("cv-paul-verse.pdf", file1, "application/pdf"), "cv2": ("cv-rafael-costa.pdf", file2, "application/pdf")},
        data={
            "request": json.dumps({
                "job": {
                    "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",
                },
                "resumes": [
                    {
                        "file": "cv1",
                        "reference": "cand-paul",
                    },
                    {
                        "file": "cv2",
                        "reference": "cand-rafael",
                    },
                ],
                "precise_ranking": True,
            }),
        },
        timeout=150,
    )
response.raise_for_status()

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

**TypeScript**

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

const form = new FormData()
form.append(
  'cv1',
  new Blob([await readFile('cv-paul-verse.pdf')], { type: 'application/pdf' }),
  'cv-paul-verse.pdf'
)
form.append(
  'cv2',
  new Blob([await readFile('cv-rafael-costa.pdf')], { type: 'application/pdf' }),
  'cv-rafael-costa.pdf'
)
form.append(
  'request',
  JSON.stringify({
    job: {
      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',
    },
    resumes: [
      {
        file: 'cv1',
        reference: 'cand-paul',
      },
      {
        file: 'cv2',
        reference: 'cand-rafael',
      },
    ],
    precise_ranking: true,
  })
)

const response = await fetch('https://hirelayer.co/api/v4/resumes/score', {
  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.scores)
```

The response is the `200` above.

### With a webhook request

With `webhook_url`, the call answers `202` at once and the scores are posted to that URL, signed: see [Webhooks](https://hirelayer.co/api-docs/webhooks.md). With storage, `results_url` reads them too.

**cURL**

```bash
# Example ids: replace job_30cfa454c97663f3e1801e5a61b87d74 with a job_id returned by POST /api/v4/jobs/extract-criteria, and file_2c982f413a5e9e79563f8a1fcb5c7caa with an application_id returned by POST /api/v4/resumes/parse.
curl -X POST https://hirelayer.co/api/v4/resumes/score \
  -H "X-API-Key: $HIRELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "job": {
      "job_id": "job_30cfa454c97663f3e1801e5a61b87d74"
    },
    "resumes": [
      {
        "application_id": "file_2c982f413a5e9e79563f8a1fcb5c7caa"
      }
    ],
    "webhook_url": "https://example.com/webhooks/hirelayer"
  }'
```

**Python**

```python
# Example ids: replace job_30cfa454c97663f3e1801e5a61b87d74 with a job_id returned by POST /api/v4/jobs/extract-criteria, and file_2c982f413a5e9e79563f8a1fcb5c7caa with an application_id returned by POST /api/v4/resumes/parse.
import os

import requests

response = requests.post(
    "https://hirelayer.co/api/v4/resumes/score",
    headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
    json={
        "job": {
            "job_id": "job_30cfa454c97663f3e1801e5a61b87d74",
        },
        "resumes": [
            {
                "application_id": "file_2c982f413a5e9e79563f8a1fcb5c7caa",
            },
        ],
        "webhook_url": "https://example.com/webhooks/hirelayer",
    },
    timeout=150,
)
response.raise_for_status()

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

**TypeScript**

```typescript
// Example ids: replace job_30cfa454c97663f3e1801e5a61b87d74 with a job_id returned by POST /api/v4/jobs/extract-criteria, and file_2c982f413a5e9e79563f8a1fcb5c7caa with an application_id returned by POST /api/v4/resumes/parse.
const response = await fetch('https://hirelayer.co/api/v4/resumes/score', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.HIRELAYER_API_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    job: {
      job_id: 'job_30cfa454c97663f3e1801e5a61b87d74',
    },
    resumes: [
      {
        application_id: 'file_2c982f413a5e9e79563f8a1fcb5c7caa',
      },
    ],
    webhook_url: 'https://example.com/webhooks/hirelayer',
  }),
  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.results_url)
```

```json
{
  "status": "pending",
  "request_id": "5d0a3c1e-2f4b-4c8e-9a61-0b7e2d9f4c13",
  "job_id": "job_30cfa454c97663f3e1801e5a61b87d74",
  "resumes_count": 1,
  "webhook_url": "https://example.com/webhooks/hirelayer",
  "results_url": "https://hirelayer.co/api/v4/jobs/job_30cfa454c97663f3e1801e5a61b87d74/scores?request_id=5d0a3c1e-2f4b-4c8e-9a61-0b7e2d9f4c13"
}
```

### Errors

| Status | `error.code` | `error.message` | When | Retry |
| --- | --- | --- | --- | --- |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `job.text must be 30 characters or more: send the job's full text.` | `param`: `job.text`. The job's text (or the text read in `job.file`) is under 30 characters. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `resumes must hold at least one resume of 30 characters or more.` | `param`: `resumes`. Every resume of the call is under 30 characters of text: one at least must reach it, the shorter ones are then scored as sent. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `job.file, job.text or job.job_id is required.` | `param`: `job`. No job, or a job with none of `file`, `text` and `job_id`; or both `file` and `text`. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `job.criteria needs job.text or job.file: a stored job_id keeps its own criteria.` | `param`: `job.criteria`. `job.criteria` (or `title`, `location`, `language`) with a stored `job_id` alone. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `job.criteria[0].weight must be 0, 1, 2 or 3.` | `param`: `job.criteria[0].weight`. A criterion breaks a rule (here its weight): `param` names the field. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `resumes must be a non-empty array.` | `param`: `resumes`. No resume, or more than 100. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `resumes[0] needs file, text, resume or application_id.` | `param`: `resumes[0]`. A resume with none of `file`, `text`, `resume` and `application_id`, or with several of the first three. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `resumes[0].file names no part of the call.` | `param`: `resumes[0].file`. A `file` (or `job.file`) that names no part of the call. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `resumes[1] is the same resume as resumes[0] (cand-42): send each resume once.` | `param`: `resumes[1].application_id`. The same resume twice in the call. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `resumes[0].application_id cannot be score: that name is reserved for POST /api/v4/resumes/score.` | `param`: `resumes[0].application_id`. An `application_id` that is not a valid id, or a name the paths reserve. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `precise_ranking is a form field: every field of the call goes in the request part's JSON, the other parts are files.` | `param`: `precise_ranking`. In multipart, a field sent as its own part instead of inside `request`. | Fix, then retry |
| `400` | [`invalid_request`](https://hirelayer.co/api-docs/errors.md#invalid_request) | `webhook_url is required without storage past 10 resume files or 50 resumes: the result is posted there, as nothing is kept to read.` | `param`: `webhook_url`. A call without storage past the synchronous limit, without `webhook_url`. | 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 |
| `400` | [`storage_required`](https://hirelayer.co/api-docs/errors.md#storage_required) | `job.job_id needs storage: send the job as text with do_not_store_data.` | `param`: `job.job_id`. A stored `job_id` without storage. | Do not retry |
| `400` | [`storage_required`](https://hirelayer.co/api-docs/errors.md#storage_required) | `resumes[0].application_id needs storage: send the resume as a file, a text or its JSON.` | `param`: `resumes[0].application_id`. A stored `application_id` alone without storage. | Do not 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 scores 38 new pairs and parses 2 files, 1 credit each: 40 credits, and 32 are left. Nothing was processed or billed.` | The credits left do not cover the call: checked before anything is parsed, stored or evaluated. | 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 |
| `404` | [`not_found`](https://hirelayer.co/api-docs/errors.md#not_found) | `No job req-7 in workspace default.` | `param`: `job.job_id`. No job under `job.job_id` in the workspace. An unknown `application_id` fails only its own score. | Do not retry |
| `413` | [`payload_too_large`](https://hirelayer.co/api-docs/errors.md#payload_too_large) | `resumes[0].file must be 4.5 MB or less.` | `param`: `resumes[0].file`. A file over 4.5 MB (`job.file` too). | Fix, then retry |
| `413` | [`payload_too_large`](https://hirelayer.co/api-docs/errors.md#payload_too_large) | `resumes[0].text must be 50,000 characters or less.` | `param`: `resumes[0].text`. A resume or job text over 50,000 characters (`job.text` too). | Fix, then retry |
| `413` | [`payload_too_large`](https://hirelayer.co/api-docs/errors.md#payload_too_large) | `resumes[0].resume must be 1 MB or less.` | `param`: `resumes[0].resume`. A parsed resume over 1 MB. | Fix, then retry |
| `413` | [`payload_too_large`](https://hirelayer.co/api-docs/errors.md#payload_too_large) | `The files of a call must be 50 MB or less in total.` | The files of the call are over 50 MB together. | Fix, then retry |
| `422` | [`job_unreadable`](https://hirelayer.co/api-docs/errors.md#job_unreadable) | `job.file has no text layer: it is probably a scan. Send its text instead.` | `param`: `job.file`. The job file cannot be read. An unreadable resume file fails only its own score. | Do not retry |
| `429` | [`rate_limited`](https://hirelayer.co/api-docs/errors.md#rate_limited) | `Too many files are being parsed at once for this account: retry after the Retry-After delay.` | Too many parses at once. Headers: `Retry-After: <seconds>`. | Retry with backoff |
| `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": "job.text must be 30 characters or more: send the job's full text.",
    "param": "job.text",
    "doc_url": "https://hirelayer.co/api-docs/errors#invalid_request"
  },
  "request_id": "6f1c2a4e-1d2b-4c3a-9e8f-0a1b2c3d4e5f"
}
```

### Behavior

- **Inputs, mixed freely in one call:** The job comes as a file, a text or a stored `job_id`, alone or (file or text only) with its criteria in the format `POST /jobs/extract-criteria` returns. Criteria without the job are refused. Each resume comes as a file, a text, the parsed resume JSON (`{"resume": {…}}`, no parsing credit) or a stored `application_id`, including one parsed by the legacy `/api/v3/parser`. A job or resume sent as content can carry your own `job_id` or `application_id`.
- **One request shape:** Without files, send `ScoreRequest` as a JSON body. With files, send `multipart/form-data`: the same JSON in the `request` part, and each file in a part whose name the JSON cites (`{"file": "cv1"}` reads the part `cv1`). A cited part that is missing is refused (`400`, `param` naming it). Every other field, `do_not_store_data` and `webhook_url` included, goes in the `request` JSON, not in a form field of its own: a text part other than `request` is refused (`400`, `param` naming it), unlike `POST /resumes/parse`, which takes its fields as form fields.
- **Limits:** at most 100 resumes per call; each file 4.5 MB at most, 50 MB of files per call. To score more files, parse them first with `POST /resumes/parse` and send their ids, or split the call. Without storage, calls of at most 10 files are fully synchronous and keep nothing, not even an undelivered webhook result. A `text` over 50,000 characters or a parsed resume JSON over 1 MB refuses the call (`413`) before any work; a resume file or JSON whose text is longer is scored on its first 50,000 characters. The job's text holds at least 30 characters, and at least one resume of the call holds 30 characters of text or more (`400`); shorter resumes beside it are scored as sent.
- **Language:** summaries and explanations are written in the job's `language` (`en`, `fr`, `de`, `es` or `pt`). Left out, it is the language of the job's text, and `en` for a text in any other language; a stored job keeps its own.
- **Each resume once:** with storage, two resumes of a call stored under the same id (the same file, text or parsed JSON sent twice without `application_id`, or the same `application_id`) refuse the call (`400 invalid_request`, `param` naming the second, such as `resumes[3]`) before any file is parsed. Without storage, nothing is stored under an id: both are evaluated and billed.
- **Unknown ids:** an unknown `job_id` refuses the call (`404`). An unknown `application_id` fails only that resume (`evaluation_status: failed`, `error.code: not_found`), unbilled.
- **Credits:** 1 per resume evaluated, plus 1 per resume file parsed. A pair already evaluated costs 0, unless it is `stale` (the resume was replaced) or its `verdict` was reset by a change of the job's criteria: then it is evaluated again in full, 1 credit. A file already stored costs 0. `precise_ranking` costs 1 credit more per call, when the call has at least 2 resumes evaluated to rank. A resume that fails (unreadable file) is not billed and does not fail the call; it comes last, with `evaluation_status: failed` and `error`. A stored job sent again with a new text or new criteria also evaluates its other stored scores again, 1 credit each, not counted in `credits_used` (see "A job sent again"); a call whose credits do not cover them is refused (`403`) before the job is changed. That check asks for the worst case: when the call sends the stored job with a new text or without one of its stored criteria, every stored score of the job counts as evaluated again. The call then bills only what is actually evaluated, which can be less.
- **Score:** weighted mean of the criterion scores, criteria marked `unknown` left out, then × 0.75 for each required criterion marked `missing` with medium or high confidence. A criterion of weight 0 is off: left out of the mean, of the penalty and of `to_verify`.
- **A job sent again:** a job text or file whose id is already stored (the same text, or your `job_id`) updates the stored job. Criteria sent with it replace the stored ones; a changed text without criteria has its criteria extracted again; the same text without criteria returns the stored job as it is, and the call's `title`, `location` and `language` are then ignored: change them with `PUT /jobs/{job_id}`. As with `PUT /jobs/{job_id}`, new criteria also evaluate the job's other stored scores again in the background, 1 credit each, not counted in `credits_used`.
- **Criterion ids:** criteria of a new job are numbered `crit_1`, `crit_2`… in their order. When criteria replace those of a stored job, one with the same label and threshold as a stored criterion keeps its id and its results; the others take the next unused numbers. An id is never reused.
- **Weights:** scoring uses the job's stored weights. To change one for good, `PATCH /jobs/{job_id}/criteria/{criterion_id}`; to try one for a single view, `GET /jobs/{job_id}/scores?weights[crit_3]=0`. Both re-rank at once, for free.
- **Without storage:** nothing is kept (see `DoNotStoreData`); `job.job_id` and each `application_id` are `null`, so give every resume a `reference` to match the results to your inputs; every call evaluates every resume again and bills it (send a resume parsed once as its JSON, `{"resume": {…}}`, so as not to pay its parsing again); `results_url` is `null`. Criteria sent back with the job keep their order, so a ranking can be recomputed on your side from `criteria[].score` and the weights. A call with `webhook_url` answers `202` at once: its files are read after it, then the job and the resumes wait until they are evaluated, and the result until it is delivered; each is deleted then, 24 hours at most after the call. A synchronous call without storage that fails (`5xx`) bills nothing, not even the files it parsed: send it again as it is.

## Re-rank with the recruiter’s priorities

With storage, every score of a job can be read again, best first, with other weights for one view: `GET /jobs/{job_id}/scores?weights[crit_3]=0` (free, nothing is evaluated again; see [Jobs and scores](https://hirelayer.co/api-docs/jobs.md#scores)). To change a weight for good, [`PATCH /jobs/{job_id}/criteria/{criterion_id}`](https://hirelayer.co/api-docs/jobs.md#update-criterion).

## Next

- [HireLayer Talent Pool Search](https://hirelayer.co/api-docs/search-resumes.md): Find the resumes closest to a job in your resume database, filtered by location and date added, in one call.
- [Build a screening pipeline](https://hirelayer.co/api-docs/screening-pipeline.md): Sync jobs and applicants from an ATS, score them, re-rank with the recruiter’s priorities and search the talent pool, with your own ids.
