ATS turns a HubSpot portal into a careers site and a hiring pipeline. Candidates apply on the customer's own domain; the application lands as a CRM record; recruiters work it from cards on the records they already use. There is no second login, no separate candidate database, and no export step between recruiting and the rest of the business.
The design decision underneath all of it: a candidate is a Contact. Not a row in a recruiting product that syncs to a Contact — the Contact itself, with recruiting properties on it. So a candidate who later becomes a customer is one record with one history, and every HubSpot list, workflow, report and marketing email works on hiring the same way it works on sales.
| Kind | Public HubSpot app (OAuth), installed per portal |
| Built for | Teams whose hiring has outgrown a mailbox but does not justify an enterprise ATS |
| Stores data in | Custom CRM objects and Contact properties on your own portal. No external database holds candidate data |
| Candidate surfaces | A careers listing, a job page per posting, an application form, and a passwordless status portal |
| Recruiter surfaces | 5 CRM cards, a full-screen recruiting app page inside HubSpot, and 4 workflow actions |
| Matching | 10 weighted dimensions, 39 published settings, a dry run that changes nothing — ats-matching.md |
| How to get it | Not distributed. Ask for a walkthrough: w@reus.ie |
Important
Maturity, stated plainly. ATS is feature-complete against its build plan and runs on our demo portals, with the matching engine covered by a unit suite that runs on every change. It has not been deployed against a real customer's live hiring. Two consequences are worth knowing before planning around it: resume parsing does not exist — a CV uploads and attaches, but nothing reads it, so matching only sees data somebody entered as properties — and the public application routes authenticate the portal, not the caller, which is appropriate for a careers page and is stated exactly in ats-integration.md. Ask for the hardening pass as part of any real deployment.
What it actually does#
A careers site on the customer's own domain#
A listing page with search and department, location, employment-type and remote filters; a detail page per posting on a permanent slug; an application form that upserts the Contact and creates the application. The embed inherits the site's existing theme, so there is no separate design exercise. Each job page is individually indexable and carries Google Jobs–compatible structured data.
Application intake that produces a record, not an email#
Name, email, phone, cover letter and an optional CV upload. Duplicate applications to the same posting are refused rather than silently doubled. What lands is an ats_application associated to a Contact and a posting — a record a workflow can act on the moment it exists.
Candidate self-service without accounts#
A candidate asks for a status link by email and receives a one-time link. No password, no account creation. The portal deliberately shows a simplified status — never the internal pipeline stage name, never a rejection reason — because the same record is the recruiter's working surface and those two audiences must not see the same field.
A recruiter workspace inside the CRM#
5 cards on the records that already exist: a posting command centre with the funnel and candidate ranking, an application workspace with the match breakdown and interviews, a candidate 360 view on every Contact, an interview panel, and a document inspector. Plus a full-screen app page — dashboard, pipeline board, postings, create-posting and reports — for the work that is not about one record.
Candidate scoring that shows its working#
The part most worth understanding, and the reason this app has a page of its own for it. A candidate is scored against a posting across 10 dimensions, each producing a 0–100 sub-score that is combined by weight. Every rule is a stored setting a recruiter can change from the settings page, and every score returns the per-dimension breakdown that produced it — which skills matched, which were missing, what the seniority gap was.
Two properties make it usable rather than merely present. A dry run scores real candidates and saves nothing, so the effect of a weight change is visible before it is committed. And a dimension the posting says nothing about drops out of the denominator rather than scoring zero — a posting with no language requirement is not a posting every candidate fails on languages.
Full treatment, for recruiters and developers alike: ats-matching.md.
Automation from HubSpot's own workflow builder#
4 workflow actions — set application stage, recalculate the match score, close a posting, create an interview — usable in any workflow the customer builds, with no extra tooling. This is where the CRM-native model pays: "when an application scores above 80, notify the hiring manager" is a workflow, not a feature request.
What it does not do#
Stated up front, because these are the ones that reshape a plan if you find them late.
- It does not read CVs. Upload, storage, metadata and a re-parse hook all exist; the extraction step behind them does not. Matching sees only structured properties, so a portal that captures nothing but a CV file has nothing to score. This is the single biggest gap and the one to raise first.
- It does not match on meaning. Skill comparison is literal, widened by a configurable synonym map (so
NodematchesNode.js). Thesemanticoption appears in the settings and currently behaves as synonym matching — there are no embeddings behind it. See ats-matching.md § Skill matching. - It does not push to job boards. There is a pull feed — one XML URL a board polls on its own schedule, alongside the structured data that makes each page indexable by Google Jobs (ats-integration.md § The job-board feed). What does not exist is an authenticated push integration that posts into Indeed's or LinkedIn's own systems and reports back a posting id.
- It does not author scorecards or offer letters. The objects exist at the higher tiers; the authoring experience is record-level property editing.
- It is not a compliance product. Compliance objects exist at full tier as data. Legal review of a regulated hiring process is not something a data model performs.
- It has no candidate accounts. The status portal is a link, by design. If a customer specifically wants candidates to log in, this is not that.
What people see#
| Who | Surface | What it is |
|---|---|---|
| Candidate | Careers listing | Search and filters over live postings, on the customer's domain and in their theme. Filtered views are shareable links |
| Candidate | Job page | One posting on a permanent slug, individually indexable, with Google Jobs structured data and related roles |
| Candidate | Application form | Contact upsert plus an application record; CV optional; duplicate applications refused |
| Candidate | Status portal | A passwordless emailed link showing a simplified status, and the ability to withdraw |
| Candidate | Job alerts | Opt-in digest of new postings matching a stated preference |
| Recruiter | 5 CRM cards | Posting command centre, application workspace, candidate 360, interview panel, document inspector |
| Recruiter | Recruiting app page | Full-screen inside HubSpot: dashboard, pipeline board, postings list, create posting, reports |
| Operator | Settings page | Status and install tier, the 39 matching settings, the dry-run calibrator, sample data |
| Automation | 4 workflow actions | Set stage · recalculate match · close posting · create interview |
Data model#
Everything is a CRM record, and the schema installs in tiers because most HubSpot portals cap custom objects at around 10 — a 19-object recruiting model does not fit on a standard portal, so it is not the default.
| Tier | Objects | What it is for |
|---|---|---|
minimal | 2 | Only what the public job board needs at runtime. This is what the OAuth callback provisions |
lean (default) | 4 | ats_job_posting, ats_application, ats_interview, ats_candidate_document — a working ATS |
standard | 6 | Adds a candidate profile and an offer object |
full | 19 | The enterprise model: requisitions, compliance, scorecards, skill junctions. Requires a raised custom-object allowance and is installed deliberately, never from the OAuth callback |
| Object | What it holds |
|---|---|
ats_job_posting | A role: its public content, its slug, its status, and the matching criteria a candidate is scored against |
ats_application | One person applying to one posting: pipeline stage, timestamps, and the persisted match score with its full breakdown |
ats_interview | A scheduled interview and its outcome |
ats_candidate_document | An uploaded CV or attachment, its metadata, and a parse-status field awaiting a parser |
contact (extended) | Recruiting properties on the standard Contact — seniority, skills, languages, education, experience, salary expectation, remote preference, open-to-work |
app_setting | Key-value configuration, using our shared app-settings pattern; the matching settings are rows here |
Three things about this model are deliberate and worth copying whatever you build on:
- The candidate is the Contact. Recruiting properties live in their own property group on the standard object. No parallel person record, no sync, no reconciliation.
- The tier is a decision, not a default. Provisioning 19 objects on a portal that allows 10 fails at install time, on the customer's portal, in a way that is tedious to unwind. Installing the smallest thing that works and upgrading additively is the safe direction, and the upgrade never deletes.
- The match score is stored with its explanation.
match_scoreis a number;match_details_jsonalongside it holds the per-dimension breakdown and a snapshot of the settings that produced it. A score you cannot explain six months later is a score nobody trusts.
Associations use labels rather than raw type ids, and object type ids are resolved from the schema list at runtime — they differ per portal, so hardcoding one is a bug that only appears on the second install.
Integration seams#
| With | What crosses |
|---|---|
| The customer's website | The careers embed is a Vite bundle on the customer's own pages, calling the app's public routes. The portal is authenticated and licence-checked; the caller is not — see ats-integration.md |
| HubSpot workflows | 4 custom actions, and the ordinary CRM surface. Everything ATS writes is a property or a record, so anything HubSpot can automate against a record it can automate against a hire |
| Marketing email and lists | Candidates are Contacts with a property group. Talent-pool nurture, alert digests and re-engagement are ordinary HubSpot marketing, not an ATS feature |
There is no outbound webhook to arbitrary URLs and no third-party job-board push. A system that wants ATS data reads it from the portal.
Where to go next#
| You are | Read |
|---|---|
| Deciding how candidates should be ranked, or tuning it | ats-matching.md — every dimension's arithmetic, all 39 settings, and how to change them safely |
| Calling it over HTTP, or building against the embed | ats-integration.md — the auth model, the generated endpoint index, the match API and the persisted score shape |
| Installing or operating it on a portal | ats-setup.md — tiers, what install provisions, the properties matching needs, verification and the schema migration |
| Evaluating whether it fits | This page, then ask for a walkthrough |