This page is written for the person who owns the portal. It is ordered as the work actually happens: decide the tier, install, provision the pages, fill the data, tune, verify. The section that most often gets skipped is § What matching needs, and skipping it is the single most common reason a working install produces useless rankings.
Before you start#
| Requirement | Why |
|---|---|
| A HubSpot plan that includes custom objects | Postings, applications, interviews and documents are custom objects. Without them the app installs but the job board cannot exist — the dynamic job page is skipped rather than left as an unpublishable draft |
| Custom-object allowance for your chosen tier | 4 objects at the default. Most Enterprise portals cap around 10; the full 19-object tier needs a raised allowance agreed with HubSpot first |
| A CMS-hosted site on the portal, if you want the careers pages provisioned | The seven standard pages are created on your own domain, in your own theme |
| An active licence for the app | Every route checks it. An inactive licence answers 403 and no amount of retrying helps |
The tier decision — make it before installing#
The schema installs in tiers because 19 objects does not fit on a normal portal. Choose the smallest one that covers the process you actually run; upgrading later is additive and never deletes.
| Tier | Objects | Choose it when |
|---|---|---|
minimal | 2 | You want the public job board and nothing else. This is what the OAuth callback provisions on its own |
lean (default) | 4 | Normal. Postings, applications, interviews and documents — a working ATS |
standard | 6 | You need a separate candidate profile object or structured offers |
full | 19 | Requisitions, compliance records, scorecards, skill junctions. Requires a raised object allowance and a deliberate decision |
Important
The OAuth install deliberately provisions only the minimal tier. A public app must not create a dozen custom objects on a portal simply because somebody clicked install. Everything above minimal is a separate, deliberate step — the Settings page's Status tab, or the tier-upgrade action. If your postings and applications are missing after install, this is why, and the fix is to upgrade the tier rather than to reinstall.
Upgrades only ever add. Going from lean to standard creates the two new objects and leaves everything else untouched; there is no downgrade, because a downgrade would mean deleting records.
What install provisions#
| Provisioned | Detail |
|---|---|
| Custom objects | Per the tier above, with their properties and association labels |
| Contact properties | An ATS – Candidate Matching property group on the standard Contact object, holding every candidate-side field matching reads |
app_setting records | The 39 matching settings, seeded at their defaults, plus app-level settings such as the public base URL |
| Careers pages | 7 standard pages, on request — /careers, /careers/jobs, the dynamic job detail page, /careers/apply, /careers/thanks, /careers/application-status and /careers/alerts. All seven embed the same module with a different view |
| Workflow actions | 4 actions, available in the workflow builder immediately |
| Sample workflows | Two, created disabled, so nothing runs until you enable it. A third — Score new applications — is not provisioned and is described in the install result instead: any workflow using one of the app's own actions must be created in the editor, because HubSpot binds it to an app connection no API can set. It is one step and worth the minute — it is what makes every new application arrive already scored |
The dynamic job-detail page is bound to the portal's own ats_job_posting object. That binding is per-portal, which is why object type ids are resolved at runtime everywhere and never hardcoded.
The settings page#
Five tabs, in the app's listing in HubSpot:
| Tab | What it is for |
|---|---|
| Status | Installed tier, which objects are present, licence state, and the tier upgrade |
| General | App-level settings — the careers site base URL used to build candidate links and the job-board feed, and the employer name that feed publishes |
| Matching | All 39 settings, grouped, plus the dry-run calibrator. ats-matching.md |
| Sample data | Load or remove a working demo dataset in one action |
| Danger zone | Destructive operations. Read § What an uninstall destroys first |
Matching settings are staged and saved as a group — nothing takes effect until Save, and Discard restores what is stored.
What matching needs#
Read this section even if you are not tuning anything. The engine scores properties, and only properties. It does not read CVs, it does not infer, and a field nobody filled is not a low score — it is a dimension that either scores a neutral 50 or drops out of the calculation entirely.
The failure this produces is quiet and specific: every candidate scores roughly the same, in a band around 50, and the ranking looks arbitrary. That is not a broken engine. That is an engine with nothing to compare.
On the job posting#
| Field | Feeds |
|---|---|
| Must-have skills · Nice-to-have skills | The two skill dimensions, and the must-have gate |
| Seniority level | Seniority |
| Remote model · City · Country | Location |
| Salary minimum and maximum (the public ones) | Salary |
| Minimum years of experience | Experience |
| Required education level | Education |
| Required languages | Languages |
| Industry | Industry |
| Match keywords | Keywords, if you have enabled that dimension |
| Match weight override (JSON) | Per-posting weights — see ats-matching.md |
A posting that states no requirement for a dimension excludes it. That is deliberate and usually right, but it means a thinly filled posting scores everyone on very little. If rankings need to be comparable across postings, make these fields required in your own process — the app does not enforce it.
On the candidate — the Contact#
Every field lives in the ATS – Candidate Matching property group: seniority level, remote preference, willing to relocate, total years of experience, primary skills, languages, education level, industry, match keywords, salary expectation minimum and maximum, and open to work.
Three ways they get filled, in descending order of how much you should rely on them:
- A workflow or an import. The reliable answer. Map whatever you already hold onto these properties.
- A recruiter, on the Contact record. Fine at low volume.
- The application form. It captures the applicant, not their full profile. It does not populate the matching fields.
There is no fourth way, and in particular there is no CV extraction. An uploaded CV attaches to the candidate and nothing reads it.
Important
ats_open_to_work is what scopes the candidate pool. A posting-to-candidates match run filters to contacts marked open to work, and scores up to 2,000 of them. If nobody is marked, the run falls back to scanning your contact base — which on a large portal means genuine candidates can fall outside the bound and never be scored at all, with only candidatePoolTruncated in the response to say so. Set this property, from the application form's own workflow if nothing else.
Migrating an already-installed portal#
Properties added after a portal was installed do not appear on it by themselves. Run the schema migration:
POST https://api.worksby.design/apps/ats/admin/migrate-schema
It is idempotent and additive — it creates what is missing and touches nothing that exists. Two properties are worth checking for specifically, because their absence is silent:
ats_open_to_work— without it the candidate pool is unfiltered, as above.stage_entered_at— without it, time-in-stage reports nothing. There is no error; the number is simply always empty.
Run this after any upgrade. A portal that has been installed for a while and has never been migrated is the usual explanation for "that feature is documented but I do not have it".
Important
Read the migration's own result, do not just check it returned 200. It reports contactProperties: { created, failed }, and a property that could not be created lands in failed while the call still reports success. Each one silently degrades a matching dimension to "missing data" for every candidate — a wrong ranking rather than an error, which is the hardest kind to notice.
The failure mode worth knowing: HubSpot requires property labels to be unique across the whole object, not just across one app's properties. A label that collides with a standard HubSpot property is rejected, and the app carries on. If failed is non-empty, the fix is on our side — send us the list.
Sample data does not update itself. The seeder reuses records that already exist rather than overwriting them, which is what stops a re-seed duplicating your demo. The consequence: if the shipped sample set gains fields, an already-seeded portal will not pick them up from a re-seed. Remove the sample data and add it again, or ask us to backfill.
Verification#
In order. Each step proves the one before it actually worked, and the last one is observable by a person rather than by a log.
- Licence —
GET /apps/ats/license-statusfor the portal answers active. - Objects — the Settings page's Status tab lists the tier and shows every expected object present.
- Pages —
/careers/jobsloads on the portal's own domain and renders the module rather than an empty page. - A posting appears — create a posting, publish it, and confirm it is on
/careers/jobs. If the list is empty, the posting is not bothliveand active; nothing else filters it. - The job page resolves — open the posting from the list. The detail page is the dynamic page; if it 404s, the dynamic page was skipped at install because the custom object did not exist yet.
- An application lands — apply through the form, then find the
ats_applicationrecord associated to both the Contact and the posting. Submitting the same form twice must produce one record, not two. - The status link works — request a status link for that email, follow it, and see the application with a simplified status.
- Matching produces a real ranking — on the Settings page's Matching tab, run the dry-run calibrator against that posting's record id. This is the step that proves the data, not just the plumbing. A spread of scores means the candidate properties are populated; every candidate clustered near 50 means they are not, and § What matching needs is where to go.
- The job-board feed resolves — open
https://api.worksby.design/apps/ats/jobs.xml?portalId=<portalId>in a browser. A409means the careers base URL is not set. Otherwise check theX-Jobs-Includedheader against the number of live postings, and click one<url>from the document — that link is what a candidate will follow from a job board, and it is the only part no test can prove for you.
When something is wrong#
| Symptom | Usual cause | Fix |
|---|---|---|
| Careers page is empty | Postings are not live, or not active | Publish the posting; check record_status |
| Job detail page 404s | The dynamic page was skipped at install because the custom object did not exist | Provision the object, then re-provision the pages |
Every route answers 403 | Licence inactive, or the portal's install record is missing | Reactivate the licence; reinstall the app if the install record is gone |
A route answers 409 naming an object | That object is not installed at the portal's tier | Upgrade the tier from the Status tab |
| Every candidate scores about the same | Candidate properties are empty | § What matching needs. This is not a matching bug |
| A candidate you expected is absent from a ranking | They are not marked open to work, or the pool bound was hit | Check candidatePoolTruncated in the response; set ats_open_to_work |
| A setting saves but changes nothing | It may be one of the 3 that are not wired to the engine | The generated table in ats-matching.md names them |
| Scores never update | Nothing recalculates automatically | Build a workflow using the recalculate-match action |
| Time-in-stage is always empty | stage_entered_at was never provisioned | Run the schema migration |
The job-board feed answers 409 | The careers site base URL is not set | Settings page → General |
| A board shows your hostname as the employer | Employer Name is unset, so the feed falls back to it | Settings page → General |
| A live posting is missing from the feed | It has no slug, or it is marked noindex | Compare X-Jobs-Skipped against the number of live postings |
| A board shows no salary on a role that has one | salary_visibility is hidden for that posting — the feed honours it | Change it on the posting if you intended to publish it |
| New applications arrive unscored | No scoring workflow exists yet — it is a manual one-step build, not provisioned | Workflow editor: enrol on application create, add Recalculate match score |
| A workflow using an ATS action does nothing, with no error | It was created over the API rather than in the editor. It will enable, report enabled, and never fire | Rebuild that step in the workflow editor |
Security posture#
The shareable summary. A deeper analysis exists and is not published — ask, and we will walk through it.
- Candidate data stays on your portal. The app stores postings, applications, candidates and documents as CRM records on your own HubSpot. No external database holds them.
- The public careers routes authenticate the portal, not the caller. That is appropriate for a public careers page: anyone may read live postings and anyone may apply. What is protected is the data — public routes return only the public field set, never another candidate's application.
- The candidate status link is a signed, expiring token bound to one contact. Requesting one always returns the same acknowledgement, so it cannot be used to discover whether somebody applied.
- The candidate never sees your internal pipeline. The status portal returns a reduced shape by design — no internal stage name, no rejection reason.
- The recruiter surface is bound to a licensed portal install rather than to an individual user, because HubSpot does not sign card-initiated calls. Stated in full in ats-integration.md.
- Workflow actions are genuine signed HubSpot webhooks, verified per request.
What an uninstall destroys#
Caution
Removing the app archives every ATS record on the portal and deletes the schemas — postings, applications, interviews, documents, and their custom properties and association labels. It discovers what to remove dynamically, so a portal that was upgraded to a higher tier has that tier's objects removed too.
It also archives app_setting, which is where your 39 tuned matching settings live. They are not backed up anywhere. If you have calibrated the engine, record the values you changed before uninstalling — a fresh install starts from the defaults.
Two things survive, and both matter:
- Contacts survive. Candidates are Contacts, and Contacts are yours. The ATS property group's values remain on them, so a reinstall finds the candidate data intact.
- Schema deletion can fail without the uninstall failing. HubSpot refuses to drop a schema while soft-deleted records or workflow references remain. That is reported in the result rather than treated as fatal, so check the result rather than assuming a clean removal. Retrying later usually clears it.
Reinstalling is not a repair tool. For a portal that is behaving oddly, the migration above fixes missing properties, and the tier upgrade fixes missing objects — both without touching data.