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.

  1. 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.
  2. 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.
  3. 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.
  4. Combine by weight. overall = Σ(score × weight) ÷ Σ(weight), over the included dimensions only.
  5. 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 onWhat happens
The posting — you did not state a requirementFour 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 dataAlways 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.

DimensionPosting silentCandidate silent
Must-have skills · Nice-to-have skills · Languages · KeywordsExcludedMissing-data behaviour
Seniority · Location · Salary · Experience · Education · IndustryMissing-data behaviourMissing-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).

DimensionSetting keyDefault weightShare of the score
Must-have skills weightweight_must_have_skills3030%
Nice-to-have skills weightweight_nice_to_have_skills1010%
Seniority weightweight_seniority1515%
Location weightweight_location1515%
Salary weightweight_salary1010%
Experience years weightweight_experience_years55%
Education weightweight_education55%
Languages weightweight_languages55%
Industry weightweight_industry55%
Keywords weight (0 = disabled)weight_keywords0disabled

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:

ParticipatingWeights sum toLocation weight 15 is worth
All 9 enabled dimensions10015% of the score
Must-have skills and location only4533% 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 listedAchievable sub-scoresA threshold of 60 demands
10, 1001 of 1 — every skill
20, 50, 1002 of 2 — every skill
30, 33, 67, 1002 of 3
40, 25, 50, 75, 1003 of 4
50, 20, 40, 60, 80, 1003 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.

BehaviourWhat happens below the thresholdUse it when
gate (default)The overall score is capped at must_have_gate_score_ceiling (default 30). The candidate still appears, visibly sunkYou want weak-on-requirements candidates ranked last but still visible
weightedNothing. Must-have skills are just another weighted dimensionThe "requirements" are aspirational and you do not want them enforced
hard_excludeThe candidate is removed from the ranking, and flagged below_threshold: trueYou 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:

MethodBehaviour
exactCase-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
semanticCurrently 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, and nothing reads a CV. 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 does not read skills out of a CV — 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:

GapScore
0100
+1, +2, +3 or more (over-qualified)From the seniority_over_qualified_penalty_mode table below
−1seniority_gap_1_score (60), softened by the mode below
−2seniority_gap_2_score (20), softened by the mode below
−3 or more0

Under-qualification table, by seniority_under_qualified_penalty_mode:

Mode−1 level−2 levels−3 or more
strict (default)The gap score as set — 60The gap score as set — 200
mildHalves the shortfall — 80Halves the shortfall — 600

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
none10010080
mild (default)805030
strict60200

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:

PostingCandidate preferenceScore
remoteremote100
remotehybrid or flexible80
remoteanything elselocation_remote_to_onsite_score (10)
hybridhybrid, remote or flexible100
hybridanything else, including blank70
onsitesame city as the posting100
onsitesame countrylocation_same_country_score (40)
onsitewilling to relocatelocation_relocation_score (50)
onsiteno city and no preference statedGlobal missing-data behaviour
onsitenone of the above0

A posting with no remote_model is treated as onsite.

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

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.

  1. If the candidate's minimum is at or below the posting's maximum — they are affordable → 100.
  2. Otherwise excess = candidateMin − postingMax and tolerance = postingMax × salary_tolerance_pct ÷ 100. If the excess is within tolerance → round(100 × (1 − excess ÷ tolerance)).
  3. Otherwise 0, and the candidate is removed from the ranking when salary_hard_filter is 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.

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 ≤ 0100
  • 0 < gap ≤ experience_years_toleranceround(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 ≤ 0100, or 80 when the candidate is above the requirement and education_over_qualified_penalty is on
  • gap = 1education_gap_1_score (40)
  • gap ≥ 20

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_allowed on (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_mapindustry_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:

BehaviourEffectWhat it says
neutral (default)Scores 50 and participates"We do not know" — neither rewarded nor punished
conservativeScores 0 and participates"Unstated is a miss" — pushes incomplete profiles down
ignoreExcluded 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:

BehaviourIts failure mode
neutralBlank 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
conservativeA 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
ignoreThe 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 39, 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 29 rule settings, in 11 groups — the 10 weights above complete the 39. A blank range means the engine imposes no bound.

Must-Have Rules

SettingKeyTypeDefaultRange / optionsShown when
Must-have behaviourmust_have_match_behaviorselectgategate · weighted · hard_excludeAlways
Gate thresholdmust_have_gate_thresholdnumber60must_have_match_behavior is gate or hard_exclude
Gate score ceilingmust_have_gate_score_ceilingnumber30must_have_match_behavior is gate

Skill Matching

SettingKeyTypeDefaultRange / optionsShown when
Skill matching methodskill_matching_methodselectsynonymexact · synonym · semanticAlways
Skill synonym mapskill_synonym_mapjson9 entries — javascript, typescript, react, …JSON objectAlways
Industry adjacency mapindustry_adjacency_mapjson5 entries — fintech, saas, ecommerce, …JSON objectAlways

Seniority Rules

SettingKeyTypeDefaultRange / optionsShown when
One-level gap scoreseniority_gap_1_scorenumber60Always
Two-level gap scoreseniority_gap_2_scorenumber20Always
Over-qualification penaltyseniority_over_qualified_penalty_modeselectmildnone · mild · strictAlways
Under-qualification penaltyseniority_under_qualified_penalty_modeselectstrictmild · strictAlways

Location Rules

SettingKeyTypeDefaultRange / optionsShown when
Remote candidate on on-site rolelocation_remote_to_onsite_scorenumber10Always
Relocation willing scorelocation_relocation_scorenumber50Always
Same country scorelocation_same_country_scorenumber40Always
Enable radius matchinglocation_use_radiusbooleanfalsetrue · falseAlways
Radius (km)location_radius_kmnumber50location_use_radius is true

Salary Rules

SettingKeyTypeDefaultRange / optionsShown when
Salary tolerance (%)salary_tolerance_pctnumber15Always
Hard salary filtersalary_hard_filterbooleanfalsetrue · falseAlways
Missing salary datasalary_missing_data_behaviorselectneutralneutral · conservative · ignoreAlways

Experience Rules

SettingKeyTypeDefaultRange / optionsShown when
Experience tolerance (years)experience_years_tolerancenumber1Always
Under-by-1yr scoreexperience_years_under_1yr_scorenumber50Always
Missing experience dataexperience_missing_data_behaviorselectneutralneutral · conservative · ignoreAlways

Education Rules

SettingKeyTypeDefaultRange / optionsShown when
One-level gap scoreeducation_gap_1_scorenumber40Always
Penalise over-qualificationeducation_over_qualified_penaltybooleanfalsetrue · falseAlways
Missing education dataeducation_missing_data_behaviorselectneutralneutral · conservative · ignoreAlways

Language Rules

SettingKeyTypeDefaultRange / optionsShown when
Allow partial language matchlanguage_partial_match_allowedbooleantruetrue · falseAlways

Industry Rules

SettingKeyTypeDefaultRange / optionsShown when
Adjacent industry scoreindustry_adjacent_scorenumber40Always

Score Display

SettingKeyTypeDefaultRange / optionsShown when
Minimum score to displayminimum_display_scorenumber0Always
Minimum must-have score to displayminimum_must_have_score_to_displaynumber0Always

Global Settings

SettingKeyTypeDefaultRange / optionsShown when
Global missing data defaultmissing_data_global_defaultselectneutralneutral · conservative · ignoreAlways

Warning

2 of these 39 settings are not read by the engine. They save, they persist, and they change nothing: location_use_radius, location_radius_km. Detected by scanning the engine source for each key, so this warning removes itself when one is wired up.

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.

DimensionScoreWhyWeightContribution
Must-have skills1003 of 3 — Node canonicalised to node.js303,000
Nice-to-have skills50Docker yes, GraphQL no10500
Seniority100Senior against senior, gap 0151,500
Location100Hybrid posting, hybrid candidate151,500
Salary100€75–85k overlaps €70–90k101,000
Experience years1006 against a minimum of 55500
Education100Bachelor's against bachelor's5500
Languages100English required and held5500
Industry100Technology both sides5500
KeywordsExcluded: weight 0, and the posting lists none0

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_method to exact and Node no longer matches Node.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 wantChange
Requirements to be genuinely non-negotiableRaise 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 postingEither 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 oneSet the missing-data behaviours to conservative. Under neutral, unstated beats stated-but-short
Weak-on-requirements candidates off the list entirelyEither 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 emptySet missing_data_global_default to conservative, and the three per-dimension ones with it
Fewer false misses on skillsExtend 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 credentialsDrop weight_education and weight_experience_years to 0; raise weight_must_have_skills
Location to decide an on-site roleOverride weight_location on that posting rather than changing the portal default
To see fewer, better candidatesRaise minimum_display_score. It filters the ranking without changing any score
To understand one specific rankingRead 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#

  • Nothing reads CVs. Matching sees only structured properties. A portal that collects CV files and no property data has nothing to score, and every candidate will look identical.
  • semantic is synonym. The option is in the settings and behaves as synonym matching.
  • 2 settings are inert — both belong to radius matching, which needs geocoding that does not exist. They save and change nothing. The generated table above names them, detected by scanning the engine source, and the settings page marks each one "not yet wired up" under its own control — so you find out where you are making the change, not afterwards.
  • The candidate pool is bounded. A posting-to-candidates run scores up to 2,000 Contacts, filtered to those marked open to work where the portal has that property. The response says whether that bound was hit; see ats-integration.md.
  • 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: false is the dimension that left the denominator. Check this before concluding a weight did nothing.
  • excluded: true distinguishes "the posting stated no requirement" from "the weight is 0" — both exclude, for different reasons.
  • engine_version is the ruleset that produced the score, and it is also written to the application's match_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_snapshot records 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/missing for skills, gap for seniority and education, candidate_min/job_max for salary.

Calling the API, persisting a score, and the workflow action are in ats-integration.md.

Change policy#

  • The generated tables — the weights and the 39 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_json are 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.