A match score answers one question: how well does this person fit this posting, and which parts of the fit are weak? It is a ranking aid for a human, not a decision. The engine is deliberately arithmetic rather than statistical — no model, no training data, no drift — so any score can be traced back to the rules that produced it, and a recruiter who disagrees with a ranking can change the rule instead of appealing to a black box.
This page is written for two readers at once. Every dimension is stated first in plain terms — what it means and what makes it go up or down — and then as the exact arithmetic, for anyone who needs to reproduce or debug a number. A head of recruitment can stop after the plain sentence in each; a developer can skip straight to the formulas.
How a score is produced#
Five steps, in this order. The order matters — the gate is applied after the weighted sum, not instead of it.
- Normalise both sides. The posting's properties and the candidate's Contact properties are read into a neutral shape. Comma-separated, semicolon-separated, newline-separated and JSON-array text all parse into lists, so a skills field entered by hand and one written by a workflow behave the same.
- Score each dimension independently, 0–100. 10 dimensions, each with its own rule. Nothing at this stage knows about weights or about any other dimension.
- Drop the dimensions that do not apply. Three things exclude a dimension, and they are not interchangeable — § Two kinds of absence below. Excluded dimensions leave the denominator entirely.
- Combine by weight.
overall = Σ(score × weight) ÷ Σ(weight), over the included dimensions only. - Apply the must-have gate. If the must-have skills sub-score — the 0–100 number from step 2, not a count of skills — is below the threshold, cap or flag the result depending on the configured behaviour.
The result is rounded once, at the end.
Two kinds of absence#
Data can be missing on either side, and the two are handled completely differently. This is the most common source of a score nobody can explain, so it is worth getting straight before anything else.
| Missing on | What happens |
|---|---|
| The posting — you did not state a requirement | Four dimensions exclude themselves. The other six fall to missing-data behaviour, exactly as if the candidate were blank |
| The candidate — they did not supply the data | Always missing-data behaviour. Never an automatic zero, unless you configure one |
Only four dimensions exclude themselves when the posting is silent, and they are the four that are lists: must-have skills, nice-to-have skills, languages and keywords. An empty list has nothing to score against, so scoring it would be meaningless.
The other six — seniority, location, salary, experience, education, industry — are comparisons between two values. A blank on either side means the comparison cannot be made, and both blanks take the same route: missing-data behaviour, which by default scores 50 and stays in the denominator.
| Dimension | Posting silent | Candidate silent |
|---|---|---|
| Must-have skills · Nice-to-have skills · Languages · Keywords | Excluded | Missing-data behaviour |
| Seniority · Location · Salary · Experience · Education · Industry | Missing-data behaviour | Missing-data behaviour |
So a posting that says nothing about education does not remove education from the score — by default it gives every candidate 50 on it. If you want a silent posting to genuinely drop a dimension, either set its weight to 0, or set the relevant missing-data behaviour to ignore. Full treatment: § Missing data.
Important
A blank field can score higher than an honest one. Take a posting requiring a master's degree. A candidate who leaves education blank scores 50 on that dimension. A candidate who states a bachelor's — one level short, and truthful — scores 40. Verified 2026-08-09.
This is what neutral means, taken to its conclusion: not knowing is treated as better than knowing something disappointing. It is the right default for a small, well-curated pool and the wrong one for a large, patchy talent pool where incomplete profiles are common. Switch the missing-data behaviours to conservative if that describes yours.
The ten dimensions and their weights#
10 dimensions, 9 enabled by default. The defaults sum to 100, so out of the box a weight reads directly as a percentage — but only while every dimension participates. The share column is that best case; the real denominator is computed per candidate (see Normalisation below).
| Dimension | Setting key | Default weight | Share of the score |
|---|---|---|---|
| Must-have skills weight | weight_must_have_skills | 30 | 30% |
| Nice-to-have skills weight | weight_nice_to_have_skills | 10 | 10% |
| Seniority weight | weight_seniority | 15 | 15% |
| Location weight | weight_location | 15 | 15% |
| Salary weight | weight_salary | 10 | 10% |
| Experience years weight | weight_experience_years | 5 | 5% |
| Education weight | weight_education | 5 | 5% |
| Languages weight | weight_languages | 5 | 5% |
| Industry weight | weight_industry | 5 | 5% |
| Keywords weight (0 = disabled) | weight_keywords | 0 | disabled |
Weights are ordinary numbers, not percentages, and nothing requires them to sum to anything. The defaults sum to 100 because that makes them readable, not because the engine needs it. Doubling every weight changes nothing at all; doubling one changes its influence relative to the rest.
A weight of 0 disables the dimension — it is removed from the calculation rather than scored zero. That is how keywords ships: present, documented, and off.
Normalisation — why a weight is not a percentage#
This is the part that surprises people, and it is the single most useful thing to understand before tuning anything.
The denominator is the sum of the weights that actually participated for this candidate, not the sum of all weights. A dimension that was excluded — because the posting stated no requirement, or because its weight is 0 — is not in the numerator or the denominator.
So the same weight is worth more when fewer dimensions participate:
| Participating | Weights sum to | Location weight 15 is worth |
|---|---|---|
| All 9 enabled dimensions | 100 | 15% of the score |
| Must-have skills and location only | 45 | 33% of the score |
Neither is wrong — the second score genuinely rests on less, so what it does rest on counts for more. But it means a thin comparison produces a confident-looking number from very little evidence, and a recruiter comparing scores across postings is not comparing like with like. If that matters, require the same fields on every posting rather than trying to fix it with weights.
Note
Leaving fields off a posting does not, by itself, produce the second row. Only the four list dimensions exclude themselves when the posting is silent; the rest score a neutral 50 and stay in the denominator (§ Two kinds of absence). Reaching a genuinely small denominator takes a deliberate act — a weight of 0, or a missing-data behaviour of ignore. A sparse posting on default settings does something different and worse: it scores everyone 50 on the fields you left out, which flattens the ranking rather than sharpening it.
The must-have gate#
The gate exists because a weighted average is too forgiving on its own. A candidate missing every required skill can still reach a respectable overall score on seniority, location and salary alone — and no amount of weight tuning fixes that, because the problem is the averaging, not the weighting.
So must-have skills get a second, non-averaged test. must_have_match_behavior chooses what happens when a candidate's must-have sub-score falls below must_have_gate_threshold (default 60).
The threshold is compared against the 0–100 sub-score, not against a count of skills — and that has a consequence worth understanding before you set it, because the sub-score can only take as many values as the posting has skills. With 3 must-haves the only achievable scores are 0, 33, 67 and 100; a threshold anywhere from 34 to 67 means exactly the same thing, "at least 2 of 3".
So the same threshold is far stricter on a short list than on a long one:
| Must-haves listed | Achievable sub-scores | A threshold of 60 demands |
|---|---|---|
| 1 | 0, 100 | 1 of 1 — every skill |
| 2 | 0, 50, 100 | 2 of 2 — every skill |
| 3 | 0, 33, 67, 100 | 2 of 3 |
| 4 | 0, 25, 50, 75, 100 | 3 of 4 |
| 5 | 0, 20, 40, 60, 80, 100 | 3 of 5 |
Verified 2026-08-09 and asserted by the engine's test suite. Two practical readings: a posting with two must-haves is running an all-or-nothing gate whether you intended one or not, and adding a fifth must-have to a posting quietly loosens the gate from "3 of 4" to "3 of 5". If you want "every listed skill, always", set the threshold to 100 and stop thinking about list length.
| Behaviour | What happens below the threshold | Use it when |
|---|---|---|
gate (default) | The overall score is capped at must_have_gate_score_ceiling (default 30). The candidate still appears, visibly sunk | You want weak-on-requirements candidates ranked last but still visible |
weighted | Nothing. Must-have skills are just another weighted dimension | The "requirements" are aspirational and you do not want them enforced |
hard_exclude | The candidate is removed from the ranking, and flagged below_threshold: true | You do not want to see them at all |
Removed candidates are still counted: a ranking reports totalScored alongside belowThreshold, so a short list reads as three were dropped rather than as only two people applied. You can always see that somebody was excluded, just not who.
If you want them ranked last but still visible, that is what gate with a ceiling is for. salary_hard_filter removes a candidate the same way, on the salary dimension.
The gate is applied after the weighted sum, so a capped score of 30 means "this candidate scored well on paper but does not have what the role requires" — which is exactly what you want a recruiter to see, rather than a fabricated low average.
If the posting lists no must-have skills, the dimension is excluded and the gate never applies (gate_applied: false). A posting with no requirements cannot fail anyone on requirements.
How each dimension scores#
Each returns 0–100. score is the sub-score; excluded means the dimension left the calculation.
Skills — must-have and nice-to-have#
In plain terms: the proportion of the listed skills the candidate has. Two skills out of three is 67.
Arithmetic: round(matched ÷ required × 100). If the posting lists no skills for that field, the dimension is excluded.
Both fields use the same rule and the same matching method; they differ only in weight and in the fact that the gate watches must-have alone.
Matching method (skill_matching_method) decides what counts as the same skill:
| Method | Behaviour |
|---|---|
exact | Case-insensitive, whitespace-trimmed string equality. Node does not match Node.js |
synonym (default) | Both sides are canonicalised through skill_synonym_map first, so Node, nodejs and node.js are one skill |
semantic | Currently identical to synonym. The option exists; there are no embeddings behind it. It is not a wrong answer, just not the one the name implies |
Warning
Selecting semantic changes nothing. The engine falls through to the same synonym-map lookup. Nothing infers that React implies JavaScript. (Résumé reading fills the skills property from a CV — see § Known limits — but it does not change how two skill strings are compared once they are there.) If you want wider matching today, the answer is the synonym map below, not this dropdown. The settings page says the same thing under the dropdown itself.
The synonym map is { "canonical": ["alias", …] }, edited as JSON on the settings page. Editing it is the highest-leverage single change available: adding your industry's vocabulary once fixes every posting at the same time, and costs nothing at scoring time.
Matching is per-term and literal. It does not infer that React implies JavaScript, and it compares only what is on the record — résumé reading is the step that puts a CV's skills there, and it is a separate stage with its own switch. See § Known limits.
Seniority#
In plain terms: how far the candidate's level is from the level the posting asks for. Exactly right scores 100; a level under scores 60; two under scores 20. Over-qualified is penalised gently by default, because a level too senior is usually a conversation, not a rejection.
Arithmetic: both sides map to a numeric rank (intern/entry 1 → c_level 9), then gap = candidate − job:
| Gap | Score |
|---|---|
| 0 | 100 |
| +1, +2, +3 or more (over-qualified) | From the seniority_over_qualified_penalty_mode table below |
| −1 | seniority_gap_1_score (60), softened by the mode below |
| −2 | seniority_gap_2_score (20), softened by the mode below |
| −3 or more | 0 |
Under-qualification table, by seniority_under_qualified_penalty_mode:
| Mode | −1 level | −2 levels | −3 or more |
|---|---|---|---|
strict (default) | The gap score as set — 60 | The gap score as set — 20 | 0 |
mild | Halves the shortfall — 80 | Halves the shortfall — 60 | 0 |
mild is computed from the two gap settings rather than replacing them, so changing a gap score still moves both modes. Three or more levels short is a mismatch either way.
Over-qualification table, by mode:
| Mode | +1 level | +2 levels | +3 or more |
|---|---|---|---|
none | 100 | 100 | 80 |
mild (default) | 80 | 50 | 30 |
strict | 60 | 20 | 0 |
If either side's value is not in the rank map, the dimension falls to the global missing-data behaviour.
Location#
In plain terms: whether the candidate can realistically work where and how the job requires. A remote job and a remote candidate is a perfect fit; an on-site job in a city the candidate does not live in is not, unless they will move.
Arithmetic, branching on the posting's remote_model:
| Posting | Candidate preference | Score |
|---|---|---|
remote | remote | 100 |
remote | hybrid or flexible | 80 |
remote | anything else | location_remote_to_onsite_score (10) |
hybrid | hybrid, remote or flexible | 100 |
hybrid | anything else, including blank | 70 |
onsite | within location_radius_km of the posting | 100 |
onsite | same city as the posting | 100 |
onsite | same country | location_same_country_score (40) |
onsite | willing to relocate | location_relocation_score (50) |
onsite | nothing known about where or how they work | Global missing-data behaviour |
onsite | none of the above | 0 |
A posting with no remote_model is treated as onsite.
The on-site rules are independent, and the candidate gets the highest one that applies. Someone in the same country who has also ticked "willing to relocate" scores 50, not 40 — each rule is a separate reason they can work on site, so being available in two ways is never worse than being available in one.
Radius, and what it replaces
Switch on Enable radius matching and the first rule becomes a measured great-circle distance instead of a city-name comparison. It needs coordinates on both records — see Geocoding in the settings, and POST /crm/geocode/backfill in ats-integration.md.
Four things worth knowing before you turn it on:
- The distance is shown whether or not it scores. Every location explanation carries
distance_kmas soon as both records are geocoded, and abasissaying which question was actually answered:distance(the radius decided it),city(a name comparison did), orworkplace(a remote or hybrid role, scored on how the two sides want to work — the distance is information only). A measurement and an estimate must never render as the same number, and the recruiter cards print the basis beside the kilometres for exactly this reason. - Inside the radius there is no gradient. 5 km does not beat 45 km when you said 50 is acceptable.
- Beyond it, nobody is excluded by distance alone. They fall through to the same country and relocation rules as anyone else. There is no decay curve, because there is no setting behind one and an invented curve cannot be explained to a recruiter afterwards.
- A candidate with no coordinates is scored by city, exactly as before — never dropped. Switching radius on must not silently empty your shortlist.
Once radius is on and a posting is geocoded, the candidate pool is fetched by a bounding-box query rather than the 2,000-record cap described under Known limits — the cap disappears for that posting.
Salary#
In plain terms: can you afford this candidate. If their expectation overlaps the posted range at all, it is a full match. Above the range, the score decays across a tolerance band and then hits zero.
Arithmetic: requires the posting's maximum and the candidate's minimum; without either it falls to salary_missing_data_behavior.
- If the two sides name different currencies, they are not compared at all — it falls to
salary_missing_data_behaviorand the explanation names both. See below. - If the candidate's minimum is at or below the posting's maximum — they are affordable → 100.
- Otherwise
excess = candidateMin − postingMaxandtolerance = postingMax × salary_tolerance_pct ÷ 100. If the excess is within tolerance →round(100 × (1 − excess ÷ tolerance)). - Otherwise 0, and the candidate is removed from the ranking when
salary_hard_filteris on.
The dimension measures affordability, not similarity. A candidate asking less than the range — even far less — is a full match, not a suspicious one. If you want to treat under-asking as a signal, that is a screening judgement for a person, not something this dimension will do for you.
Currency, and why nothing is converted. Until 2026-08-11 this compared two bare numbers whatever currency each was in: a €60,000 expectation and a $60,000 posting were the same figure to it. Nobody had noticed, because nothing filled the candidate side until résumé reading started writing it. The fix is to refuse rather than convert — a conversion needs a live exchange rate, and a stale rate would misscore silently, which is the same class of failure being fixed. So:
- Both sides name the same currency → compared normally.
- Both name a currency and they differ → not compared; the dimension reports missing data and says which two currencies it saw.
- Either side is silent, or names something that is not a three-letter code → compared as before. "The candidate did not say" is not evidence that they meant a different currency, and every score recorded before this change is in that position.
A posting's currency is public_salary_currency; a candidate's is ats_salary_expectation_currency, which résumé reading fills when the document states one.
Experience years#
In plain terms: the candidate's total years against the posting's minimum. At or above the minimum is a full match; below it decays to zero across a tolerance band.
Arithmetic: gap = postingMin − candidateYears, and floor is experience_years_under_1yr_score.
gap ≤ 0→ 1000 < gap ≤ experience_years_tolerance→round(floor + (100 − floor) × (1 − gap ÷ tolerance))- otherwise → 0
The two settings mean what their names say: tolerance is how far short you will tolerate, and under-by-1yr score is what a candidate scores at that limit. On the defaults, one year short scores exactly 50 and half a year short scores 75.
Education#
In plain terms: does the candidate hold the level of qualification the posting requires. Meeting or exceeding it is a full match; one level short scores 40; two or more short scores zero.
Arithmetic: both sides map to a rank (high_school 1, associate 2, bachelor 3, master/mba 4, phd/doctorate 5), then gap = required − candidate.
gap ≤ 0→ 100, or 80 when the candidate is above the requirement andeducation_over_qualified_penaltyis ongap = 1→education_gap_1_score(40)gap ≥ 2→ 0
Without a mapped value on either side, education_missing_data_behavior applies.
Languages#
In plain terms: the proportion of the required languages the candidate has, or all-or-nothing if you turn partial credit off.
Arithmetic: ratio = matched ÷ required.
language_partial_match_allowedon (default) →round(ratio × 100)- off → 100 if every required language is present, otherwise 0
The posting listing no languages excludes the dimension. The candidate listing none falls to the global missing-data behaviour. Comparison is on the literal value, so English and en are different languages unless you standardise the data.
Industry#
In plain terms: the same industry scores 100, a related one scores 40, anything else scores 0. There is no sliding scale between them — adjacency is a yes or no, and 40 is the whole answer for every adjacent pair.
Arithmetic: equal (case-insensitively) → 100. Otherwise, if either side appears in the other's list in industry_adjacency_map → industry_adjacent_score (40 by default). Otherwise 0. Either side blank → global missing-data behaviour.
So a fintech role and a banking candidate score 40 on this dimension, not 100 and not 50. Raise industry_adjacent_score if your sector genuinely treats adjacent experience as near-equivalent — it is one number and it applies to every adjacent pair at once.
Adjacency is checked in both directions, so declaring fintech: ["banking"] also makes banking adjacent to fintech. You do not need to list a pair twice.
Keywords#
In plain terms: free-text overlap between the posting's keywords and the candidate's. Off by default.
Arithmetic: min(100, round(overlap ÷ postingKeywords × 100)). No keywords on the posting excludes the dimension, and the default weight of 0 excludes it anyway.
Turn it on when you have a vocabulary that is genuinely not a skill — a clearance, a certification body, a domain. Turning it on without curating both sides adds noise, not signal.
Missing data#
§ Two kinds of absence covers which dimensions take this route and when. This is what happens once they do — a policy decision rather than an arithmetic one, and the one setting most worth changing deliberately. Three behaviours:
| Behaviour | Effect | What it says |
|---|---|---|
neutral (default) | Scores 50 and participates | "We do not know" — neither rewarded nor punished |
conservative | Scores 0 and participates | "Unstated is a miss" — pushes incomplete profiles down |
ignore | Excluded from the calculation entirely | "Score only what we know" — the remaining dimensions carry the whole weight |
Salary, experience and education each have their own setting; every other dimension uses missing_data_global_default.
The choice interacts with the normalisation rule above: ignore shrinks the denominator, so a candidate with almost no data can score highly on the one or two dimensions they do have. conservative is the safer default for a large, patchy talent pool; neutral is the safer one when you are matching a handful of well-filled records.
Each behaviour has a failure mode, and picking one means choosing which you would rather have:
| Behaviour | Its failure mode |
|---|---|
neutral | Blank beats honest — an unstated field outscores a stated-but-short one, and a profile of nothing but blanks lands near 50 rather than at the bottom |
conservative | A genuinely strong candidate whose record is thin ranks below a mediocre one whose record is complete. You are ranking data quality as much as fit |
ignore | The denominator shrinks to whatever the candidate happened to fill in, so scores stop being comparable between candidates — an empty profile can reach 100 on one dimension |
There is no safe universal answer. The useful question is which of those three sentences you would least mind explaining to a hiring manager.
Every setting#
All 47, as the engine reads them. Their values live as app_setting records on the portal under app_name = "ats-matching"; anything unset falls back to the default shown here.
The 47 rule settings, in 14 groups — the 10 weights above complete the 57. A blank range means the engine imposes no bound.
Must-Have Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Must-have behaviour | must_have_match_behavior | select | gate | gate · weighted · hard_exclude | Always |
| Gate threshold | must_have_gate_threshold | number | 60 | — | must_have_match_behavior is gate or hard_exclude |
| Gate score ceiling | must_have_gate_score_ceiling | number | 30 | — | must_have_match_behavior is gate |
Skill Matching
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Skill matching method | skill_matching_method | select | synonym | exact · synonym · semantic | Always |
| Skill synonym map | skill_synonym_map | json | 9 entries — javascript, typescript, react, … | JSON object | Always |
| Industry adjacency map | industry_adjacency_map | json | 5 entries — fintech, saas, ecommerce, … | JSON object | Always |
Seniority Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| One-level gap score | seniority_gap_1_score | number | 60 | — | Always |
| Two-level gap score | seniority_gap_2_score | number | 20 | — | Always |
| Over-qualification penalty | seniority_over_qualified_penalty_mode | select | mild | none · mild · strict | Always |
| Under-qualification penalty | seniority_under_qualified_penalty_mode | select | strict | mild · strict | Always |
Location Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Remote candidate on on-site role | location_remote_to_onsite_score | number | 10 | — | Always |
| Relocation willing score | location_relocation_score | number | 50 | — | Always |
| Same country score | location_same_country_score | number | 40 | — | Always |
| Enable radius matching | location_use_radius | boolean | false | true · false | Always |
| Radius (km) | location_radius_km | number | 50 | — | location_use_radius is true |
Salary Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Salary tolerance (%) | salary_tolerance_pct | number | 15 | — | Always |
| Hard salary filter | salary_hard_filter | boolean | false | true · false | Always |
| Missing salary data | salary_missing_data_behavior | select | neutral | neutral · conservative · ignore | Always |
Experience Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Experience tolerance (years) | experience_years_tolerance | number | 1 | — | Always |
| Under-by-1yr score | experience_years_under_1yr_score | number | 50 | — | Always |
| Missing experience data | experience_missing_data_behavior | select | neutral | neutral · conservative · ignore | Always |
Education Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| One-level gap score | education_gap_1_score | number | 40 | — | Always |
| Penalise over-qualification | education_over_qualified_penalty | boolean | false | true · false | Always |
| Missing education data | education_missing_data_behavior | select | neutral | neutral · conservative · ignore | Always |
Language Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Allow partial language match | language_partial_match_allowed | boolean | true | true · false | Always |
Industry Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Adjacent industry score | industry_adjacent_score | number | 40 | — | Always |
Score Display
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Minimum score to display | minimum_display_score | number | 0 | — | Always |
| Minimum must-have score to display | minimum_must_have_score_to_display | number | 0 | — | Always |
Global Settings
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Global missing data default | missing_data_global_default | select | neutral | neutral · conservative · ignore | Always |
CV Parsing
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Read uploaded resumes | cv_parse_enabled | boolean | false | true · false | Always |
| AI provider | cv_ai_provider | select | claude | claude · openai · gemini | cv_parse_enabled is true |
| AI API key | cv_ai_api_key | password | — | — | cv_parse_enabled is true |
| Model (optional) | cv_ai_model | text | — | — | cv_parse_enabled is true |
| Extraction prompt | cv_ai_prompt | textarea | `You are reading a job applicant's résumé for a recruiting system. What you return is written | ||
| onto their contact record and used to score them against open roles, so two different mistakes | |||||
| both cost the candidate: missing something they did say, and inventing something they did not. |
READ THE WHOLE DOCUMENT. Most of what matters is not under the heading you would expect — skills usually appear inside the job descriptions rather than in a "Skills" list, and salary, relocation and availability are often one line at the very end. Do not stop at the first page, and do not skip a section because it looks like formatting.
The résumé may be in any language. Return free text in the language it is written in, but map every listed option — seniority, education level, remote preference — to the exact English values given for that field.
Extract only what the document states. If something is not stated, leave the field empty. An empty field is treated as "unknown" and stops counting against the candidate; a wrong one is scored as if it were true. When you are genuinely unsure between two values, leave it empty. Specifically:
- An employer's industry is not the candidate's skill.
- Do not add up date ranges into a total of years unless the document gives that total.
- Applying for a job somewhere else says nothing about willingness to relocate.
- A city of residence says nothing about wanting to work remotely.
Seniority is the one place people are too cautious. A job title that NAMES a level — "Senior Engineer", "Lead Developer", "Head of Finance" — is the document saying so, not you inferring it, so use it. What you must not do is derive a level from years of experience, from the size of a team, or from a title that names no level at all.
Skills are named technologies, tools, methods, certifications and qualifications — never personality traits. List each one once, in the form the industry uses ("PostgreSQL", not "postgres database experience"). Include skills you find in the experience section.
Return dates as YYYY-MM-DD. Return salaries as plain annual gross numbers, with the currency in
its own field.| — |cv_parse_enabledistrue| | Read a résumé as soon as it is uploaded |cv_parse_on_upload| boolean |true|true·false|cv_parse_enabledistrue| | Largest résumé to read (MB) |cv_max_file_mb| number |10| 1–10 |cv_parse_enabledistrue| | Most résumés to read per day (0 = no limit) |cv_max_parses_per_day| number |200| ≥ 0 |cv_parse_enabledistrue| | Write what was found onto the contact |cv_write_contact_fields| boolean |true|true·false|cv_parse_enabledistrue| | Overwrite values a recruiter already entered |cv_overwrite_existing| boolean |false|true·false|cv_parse_enabledistrue` |
Geocoding
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Turn addresses into coordinates | geo_enabled | boolean | false | true · false | Always |
| Geocoding provider | geo_provider | select | opencage | opencage | geo_enabled is true |
| OpenCage API key | geo_api_key | password | — | — | geo_enabled is true |
| Lookups per second | geo_requests_per_second | number | 1 | 1–40 | geo_enabled is true |
| Look up a candidate's coordinates after reading their résumé | geo_on_apply | boolean | true | true · false | geo_enabled is true |
Travel Time
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Show how long the commute takes | travel_enabled | boolean | false | true · false | Always |
| Google Maps API key | travel_api_key | password | — | — | travel_enabled is true |
| How they travel | travel_mode | select | driving | driving · transit | travel_enabled is true |
Where weights can be changed#
Three places, in increasing order of precedence.
1. The settings page — portal-wide#
The app's Settings → Matching tab renders every setting above, grouped, with the conditional ones appearing only when they apply. Changes are staged and saved as a group: nothing takes effect until you press Save, and Discard restores what is stored.
This is the right place for a policy that is true of your hiring generally — your synonym vocabulary, your missing-data stance, your gate threshold.
2. Per posting — match_weight_override_json#
A property on ats_job_posting. Set it to a JSON object of weight keys and the posting scores with those weights instead of the portal's:
{ "weight_location": 40, "weight_salary": 0 }
Only the keys you name are overridden; everything else falls through to the portal settings. Only weight_* keys are read — the rules (thresholds, tolerances, maps) are portal-wide and cannot be overridden per posting.
This is for the posting that genuinely differs: an on-site role where location is the whole problem, or a role where budget is fixed and salary fit should not be scored at all.
3. The dry run — change nothing, see everything#
Under the settings, Dry-run calibration takes a job posting's record ID and ranks real candidates against it using the settings currently on screen, saved or not. It writes nothing: no score is persisted, no record is touched.
Use it as the loop: change a weight, preview, compare the order against what you would have done by hand, then save or discard. It is the difference between tuning and guessing, and it costs nothing to run repeatedly.
Tip
Calibrate against a posting you have already hired for, where you know who the right answer was. A ranking that puts your actual hire in the top three is calibrated; one that does not is telling you which dimension is mis-weighted.
A worked example#
A senior React role, and a candidate who fits it well. Portal defaults throughout. These numbers are asserted by the engine's own unit suite, so they cannot drift away from the code.
The posting: Senior React Engineer · Amsterdam, Netherlands · hybrid · €70,000–90,000 · must-have React, TypeScript, Node.js · nice-to-have GraphQL, Docker · English · bachelor's · 5 years minimum · technology.
The candidate: senior · Amsterdam, Netherlands · prefers hybrid · asking €75,000–85,000 · React, JS, TypeScript, Node, Docker, Python · English and Dutch · bachelor's · 6 years · technology.
| Dimension | Score | Why | Weight | Contribution |
|---|---|---|---|---|
| Must-have skills | 100 | 3 of 3 — Node canonicalised to node.js | 30 | 3,000 |
| Nice-to-have skills | 50 | Docker yes, GraphQL no | 10 | 500 |
| Seniority | 100 | Senior against senior, gap 0 | 15 | 1,500 |
| Location | 100 | Hybrid posting, hybrid candidate | 15 | 1,500 |
| Salary | 100 | €75–85k overlaps €70–90k | 10 | 1,000 |
| Experience years | 100 | 6 against a minimum of 5 | 5 | 500 |
| Education | 100 | Bachelor's against bachelor's | 5 | 500 |
| Languages | 100 | English required and held | 5 | 500 |
| Industry | 100 | Technology both sides | 5 | 500 |
| Keywords | — | Excluded: weight 0, and the posting lists none | 0 | — |
9,500 ÷ 100 = 95. Must-have is 100, comfortably above the gate's threshold of 60, so the gate is applied but not triggered. Overall: 95.
Two variations worth seeing, both also asserted by the suite:
- Switch
skill_matching_methodtoexactandNodeno longer matchesNode.js. Must-have drops to 67 — still above the threshold, so the gate stays open — and overall drops to 85. - Give the candidate only React and Python. Must-have is 33, below the threshold of 60, so the gate triggers and caps the overall at the ceiling: 30, down from what the weighted average alone would have produced.
Tuning recipes#
Concrete starting points. Each is a small change with a predictable effect — make one at a time and dry-run it.
| You want | Change |
|---|---|
| Requirements to be genuinely non-negotiable | Raise must_have_gate_threshold to 100 and leave the behaviour on gate. Anything short of every listed skill is capped, whatever the list length |
| The gate to mean the same thing on every posting | Either set the threshold to 100, or standardise how many must-haves a posting may list. At 60 the gate silently loosens as the list grows — § The must-have gate |
| A blank profile to stop outscoring an honest one | Set the missing-data behaviours to conservative. Under neutral, unstated beats stated-but-short |
| Weak-on-requirements candidates off the list entirely | Either must_have_match_behavior: hard_exclude, or minimum_must_have_score_to_display at your threshold. The first drops anyone under the gate threshold; the second is an independent floor and works whatever the behaviour |
| To stop rewarding profiles that are simply empty | Set missing_data_global_default to conservative, and the three per-dimension ones with it |
| Fewer false misses on skills | Extend skill_synonym_map with your own vocabulary before touching any weight. It is the cheapest accuracy you will get |
| To hire for potential rather than credentials | Drop weight_education and weight_experience_years to 0; raise weight_must_have_skills |
| Location to decide an on-site role | Override weight_location on that posting rather than changing the portal default |
| To see fewer, better candidates | Raise minimum_display_score. It filters the ranking without changing any score |
| To understand one specific ranking | Read match_details_json on the application — every sub-score, every matched and missing skill, and the settings used |
Two habits worth more than any single setting. Tune the data before the weights — most bad rankings are a posting with three fields filled, not a weighting error. And change one thing at a time, because the normalisation rule means a change to one dimension moves every other dimension's share of the result.
Known limits#
- A candidate is only as scoreable as their record. Matching reads structured properties. Résumé reading (see CV Parsing) fills most of them, but it is off by default and needs your own provider key — until it is on, a portal that collects CV files and no property data has nothing to score and every candidate looks identical. (This entry read "Nothing reads CVs" until 2026-08-11, which had been untrue since résumé parsing shipped earlier that day.)
semanticissynonym. The option is in the settings and behaves as synonym matching.- Every setting is read by something. That was not true until 2026-08-11 — the two radius settings saved and changed nothing. It is checked by scanning the modules that read settings, so the warning that used to sit here reappears by itself the day a setting stops being read.
- The candidate pool is bounded, unless radius matching is on. A posting-to-candidates run scores up to 2,000 Contacts, filtered to those marked open to work where the portal has that property. With radius matching on and the posting geocoded, the pool is instead everyone inside the circle, with no cap. Either way the response says whether a bound was hit; see ats-integration.md.
- Travel time is not a thing the engine knows, deliberately. Distance is straight-line. The app can show a driving or transit commute next to a match (Settings → Travel Time), but it is display-only and never reaches a score, a filter or a sort: it is a live third-party value that differs between two runs an hour apart, so a ranking built on it could not be reproduced or explained after the fact. It is also stored nowhere, which means it is unavailable to workflows, lists, reports and exports — and that its cost follows how often a card is opened rather than how many candidates you have. It is fetched only when a recruiter presses the button, for the candidates on screen, and identical addresses are charged once.
- No score is automatic until you build the workflow. Scores are produced on request — a card, a dry run, or the recalculate workflow action. To have every application arrive scored, add a workflow enrolling on application creation with the Recalculate match score action as its only step: one step, about a minute in the workflow editor. It is not provisioned for you, and that is a platform constraint rather than an omission — a workflow step using an app's own action has to be created in the editor, because HubSpot binds it to an app connection the API cannot set. Nothing recalculates when a Contact or a posting is later edited either; that is a second workflow, enrolling on the property changes you care about.
What the engine returns#
Every score carries its own explanation. The same object is what the API returns per candidate and what is persisted to match_details_json on the application.
{
"overall": 95,
"engine_version": "v3",
"must_have_score": 100,
"gate_applied": true,
"gate_triggered": false,
"below_threshold": false,
"dimensions": {
"must_have_skills": {
"weight": 30, "score": 100, "included": true,
"matched": ["React", "TypeScript", "Node.js"], "missing": []
},
"seniority": {
"weight": 15, "score": 100, "included": true,
"gap": 0, "candidate": "senior", "job": "senior"
},
"keywords": { "weight": 0, "included": false, "excluded": true }
},
"settings_snapshot": {
"must_have_match_behavior": "gate",
"skill_matching_method": "synonym"
}
}
Reading it:
included: falseis the dimension that left the denominator. Check this before concluding a weight did nothing.excluded: truedistinguishes "the posting stated no requirement" from "the weight is 0" — both exclude, for different reasons.engine_versionis the ruleset that produced the score, and it is also written to the application'smatch_score_version. It moves only when a change would make the same inputs score differently — so two scores carrying the same version are comparable, and two carrying different versions are not. Check it before comparing scores written at different times, and recalculate rather than compare across a change.settings_snapshotrecords the two settings that most change the meaning of a score, so a stored score from six months ago can still be read correctly.- Each dimension carries its own diagnostic fields —
matched/missingfor skills,gapfor seniority and education,candidate_min/job_maxfor salary.
Calling the API, persisting a score, and the workflow action are in ats-integration.md.
Change policy#
- The generated tables — the weights and all 57 settings — are regenerated from the engine's own registry and checked on every commit. They cannot drift from the code.
- The worked example and the dimension arithmetic are asserted by the engine's unit suite, which runs on every change to it. A rule that changes fails a test before it reaches this page.
- Defaults may change between versions. Anything you have explicitly saved is stored on your own portal and survives; anything left unset follows the default.
- The dimension set and the shape of
match_details_jsonare the stable parts. New dimensions would be additive, with a default weight of 0. - A rule change bumps
engine_version. That is the contract: if the same inputs would score differently, the version moves, so you can always tell whether two stored scores are comparable. A refactor or a new diagnostic field does not move it. - Everything else carries the
verified:date at the top of this page and no stronger guarantee.