The ATS has three HTTP surfaces with three different trust models, and nearly every integration mistake is a call made against the wrong one. Read this section before the endpoint table.
The three surfaces#
| Surface | Path | What is authenticated | Who calls it |
|---|---|---|---|
| Public embed | /apps/ats/* | The portal, and its licence — not the caller | The careers site, from a visitor's browser |
| Candidate self-service | /apps/ats/fetch-my-applications, /withdraw-application | A signed status-link token, which is the identity | A candidate holding an emailed link |
| Recruiter and automation | /apps/ats/crm/*, /apps/ats/wf/* | A licensed portal install (CRM) · a HubSpot v3 signature (workflow) | The CRM cards and app page · HubSpot's workflow engine |
The public surface authenticates the portal, not the person, and this is correct for what it is. A careers page is public: anyone may read live postings and anyone may apply. There is no credential a browser on a public page could hold that a determined caller could not also hold. So the gate proves the request is for a portal that has installed and licensed the app, and the data is what is protected — the public routes return only the posting fields marked public, and never another candidate's application.
The candidate surface is different. A status link is a signed token bound to one contact on one portal. It is the whole credential — there is no password beside it — so treat it exactly as you would a session token: never log it, never put it in a URL you did not construct, and expect it to expire. request-status-link deliberately returns the same acknowledgement whether or not the email exists, so it cannot be used to test whether somebody has applied.
Note
The CRM surface authenticates the portal install, not the individual user, and that is a platform constraint rather than a choice. Cards call it through the UI-extension SDK's hubspot.fetch(), which HubSpot does not sign — signatures exist for server-to-server webhooks, not for card-initiated calls. So the check is that the claimed portal has a valid OAuth install and an active licence, which is the strongest binding available. It is stated here because an IT reviewer should hear it from the documentation rather than discover it. The workflow surface has no such limitation: those are genuine HubSpot-signed webhooks.
Endpoint index#
Base URL: https://api.worksby.design/apps/ats. The table is generated from the service's own policy manifest, which a route-coverage test asserts against the real routers — a route cannot ship without declaring its policy, and this table cannot drift from that declaration.
11 endpoints — 8 public, 2 customer, 1 admin.
| Endpoint | Surface | Auth required |
|---|---|---|
GET /apps/ats/jobs.xml | Public | Portal + licence |
GET /apps/ats/license-status | Public | None |
POST /apps/ats/create-application | Public | Portal + licence |
POST /apps/ats/fetch-posting-details | Public | Portal + licence |
POST /apps/ats/fetch-postings | Public | Portal + licence |
POST /apps/ats/request-status-link | Public | Portal + licence |
POST /apps/ats/subscribe-alerts | Public | Portal + licence |
POST /apps/ats/upload-document | Public | Portal + licence |
POST /apps/ats/fetch-my-applications | Customer | A signed token, which is itself the identity |
POST /apps/ats/withdraw-application | Customer | A signed token, which is itself the identity |
POST /apps/ats/admin/migrate-schema | Admin | Portal + licence |
What is deliberately not in that table. The /crm/* and /wf/* sub-routers are separate Express routers mounted ahead of the portal gate, each carrying its own check, and the coverage test does not assert them against this manifest. They are not an outsider-callable contract: /crm/* is the recruiter's own cards talking to their own portal, and /wf/* answers HubSpot's workflow engine. The ones worth documenting are described below by name; the rest are the app's operation rather than its integration surface.
The careers embed contract#
Four calls carry the whole public flow. All are POST, all take JSON, and all resolve the portal from the embed's own configuration.
| Step | Call | Notes |
|---|---|---|
| List | POST /fetch-postings | Live and active postings only, newest first, bounded at 1,000. Returns the public field set — never internal notes, never salary bands you have not marked public |
| Detail | POST /fetch-posting-details | { id, isSlug }. Resolves by slug or by public_posting_id, trying the hinted one first |
| Apply | POST /create-application | { publicPostingId, applicant: { email, … }, contactId?, source? } |
| Attach a CV | POST /upload-document | Multipart. Stores the file and creates the document record |
Three behaviours of create-application that decide how you build the form:
- Duplicates are answered, not refused. One active application per posting and email. A repeat returns
200with{ duplicate: true, applicationId }— so a double submit is idempotent, and your UI should treat that response as success rather than as an error. - A closed posting refuses with
409. Applications are accepted while a posting is live;closed,archivedandhiddenare declined with a message meant for the candidate. contactIdis a hint, not an identity. It comes from the page's tracked-visitor cookie and is honoured only when that contact's own email already matches what the applicant typed. A visitor browsing as a known contact who then applies under a different address creates or matches the record for the address they typed. Never rely oncontactIdto mean "this is who is applying".
Two further public calls exist for the candidate-facing extras: POST /subscribe-alerts opts a contact into the job-alert digest, and POST /request-status-link starts the status flow below.
The job-board feed#
GET https://api.worksby.design/apps/ats/jobs.xml?portalId=<portalId>
One URL, handed to a job board once. Aggregators poll it on their own schedule; nothing is pushed and there is nothing to maintain after the first setup.
This is not the same mechanism as the structured data on each job page, and the difference decides whether you need it. JSON-LD serves crawlers that visit a page — Google Jobs finds roles that way. A feed serves aggregators that ingest a URL and never crawl the site at all. An app with only the first is invisible to the second, so most portals want both.
The format is Indeed's <source> schema, which most boards accept. Content type is application/xml, cached for 15 minutes.
| Field | From |
|---|---|
title · city · category | Posting title, public location label, department |
url | The careers base URL joined to the posting's slug |
referencenumber | public_posting_id — stable across edits, so a board updates rather than duplicates |
date | first_published_at |
company · publisher | The Employer Name setting, falling back to the careers hostname |
description | The four public content fields, flattened to text |
salary | Only what salary_visibility permits — see below |
jobtype · remotetype | Employment type and remote model, mapped to the feed vocabulary |
What it will not publish#
Three exclusions, each deliberate:
- A posting whose salary is hidden has no
<salary>element.salary_visibilityis an editorial decision per posting, and a feed is a republication into a channel nobody reviews.hiddenmeans hidden here too;minimum,maximumandtext_onlyeach render exactly what the public job page would. - A posting marked
noindexis omitted entirely. You said do not index it. - A posting that cannot produce a working link is omitted — no slug, or no careers base URL configured. This one matters more than it sounds: an aggregator given a job with a dead URL does not skip it, it publishes it, and the candidate lands on a 404 with your name on it. Absent beats broken.
Omissions are counted rather than hidden. The response carries X-Jobs-Included and X-Jobs-Skipped, and anything skipped is logged.
Important
Set the careers base URL before you give this URL to a board. Without it the feed answers 409 with a message rather than an empty document — deliberately, because a valid-but-empty feed tells an aggregator you have no open roles, and some stop polling after enough of those. Set Employer Name too: the fallback publishes your careers hostname as the employer, which reads as a broken listing to a candidate.
The candidate status flow#
- The candidate submits an email to
POST /request-status-linkwith the URL of your status page. The response is always the same acknowledgement. - If they have applications on file, they receive an email with
?token=…appended to that page. - Your page calls
POST /fetch-my-applicationswith the token. An invalid or expired token gets401and the message to request a new link — treat it as a prompt, not a failure. POST /withdraw-applicationwithdraws one application, with the same token.
What comes back is deliberately thin: the application's own identifier, the posting title, the date applied, and a simplified step. The internal pipeline stage and any rejection detail are never in this response. If you are building the status page, do not attempt to enrich it from elsewhere — the reduction is the feature.
The match API#
Ranking lives on the CRM surface (/apps/ats/crm/*), because it reads across the whole candidate pool rather than one public record. The arithmetic behind every number here is ats-matching.md; this section is the call.
POST /crm/match/run — rank, without writing anything#
Two modes. Neither persists: this is the same call the settings page's dry-run calibrator makes, which is why calibration is free of consequences.
{ "mode": "posting_to_candidates", "sourceId": "<postingRecordId>", "limit": 10 }
| Mode | sourceId | What it does |
|---|---|---|
posting_to_candidates (default) | A job posting record id | Scores the candidate pool against that posting and returns the top limit |
candidate_to_postings | A contact record id | Scores that contact against every live posting and returns the best limit |
The response for posting_to_candidates:
{
"success": true,
"mode": "posting_to_candidates",
"job": { "id": "1234", "title": "Senior React Engineer" },
"candidatePoolSize": 312,
"candidatePoolTruncated": false,
"topMatches": [
{
"rank": 1,
"contactId": "5678",
"name": "…", "email": "…",
"overall": 95,
"mustHaveScore": 100,
"gateTriggered": false,
"matched": ["React", "TypeScript", "Node.js"],
"missing": [],
"details": { "…": "the full match object — see ats-matching.md" }
}
],
"totalScored": 312,
"belowThreshold": 47,
"executionMs": 1840
}
Four fields decide whether you can trust the ranking, and all four are easy to skip:
candidatePoolSizeis how many contacts were actually scored, after filtering.candidatePoolTruncatedwarns that the pool hit its bound. Check it. A truncated pool means a genuinely good candidate may simply not have been read — a ranking that is silently incomplete is worse than one that says so.totalScoredversusbelowThresholdis how many were removed by the display minimums rather than by their score. AtopMatchesshorter thanlimitis usually this, not a shortage of candidates.executionMsis the honest cost. A full-tier pool costs several seconds and several HubSpot search calls; do not put this call in a loop or on a page load.
Per-posting weight overrides are applied automatically. If the posting carries match_weight_override_json, /match/run reads it and scores with it — you do not pass overrides in the request.
POST /crm/match/persist — store a score on an application#
{ "applicationId": "<applicationRecordId>", "match": { "overall": 95, "rank": 1, "…": "…" }, "version": "v2" }
Writes match_score, match_score_version, candidate_rank and match_details_json onto the application. match_details_json is truncated at 65,000 characters to stay inside HubSpot's property limit — a concern only for a posting with a very large number of listed skills.
match_score_version is taken from the engine that produced the score, not from your request. You may override it, but do not — it is what tells a later reader whether two stored scores are comparable. See ats-matching.md § What the engine returns.
Ranking and persisting are separate calls on purpose: scoring is cheap and repeatable, writing to the CRM is neither.
The recalculate-match workflow action#
The supported way to keep a score current. Add it to any HubSpot workflow enrolling ats_application records; it reads the application's associated contact and posting, scores the pair with the same engine, and by default writes the result back.
| Input | persist — set it to false for a scored-but-not-stored run |
| Outputs | match_score, gate_triggered — both usable in later workflow branches |
| On failure | FAIL_CONTINUE with the reason, so a broken record does not halt the workflow |
It fails cleanly and reports why when the application has no linked candidate or posting — an application that was created outside the normal flow is the usual cause.
Important
Nothing recalculates a score by itself. No score updates when a Contact's skills change, when a posting's requirements are edited, or when settings are saved. A stored match_score is a snapshot of the moment it was written, and match_details_json carries settings_snapshot precisely so an old score can still be read correctly. If you want scores to track reality, build the workflow — enrol on the property changes you care about and call the action.
Errors#
Every route answers { success: false, error: "…" } with a meaningful status. Prescribed behaviour rather than a list of codes:
| Status | Means | What your integration should do |
|---|---|---|
400 | A required field is missing or unusable | Fix the call. Never retry unchanged |
401 | The status-link token is invalid or expired | Prompt for a new link. This is an ordinary state, not an error to report |
403 | The portal is not installed, or its licence is inactive | Stop. Retrying will not help; this needs an operator |
404 | The posting does not exist, or is not public | Treat as gone. Do not fall back to listing everything |
409 | The posting no longer accepts applications, or a required object is not installed on the portal | Show the candidate the message; escalate the install case to an operator |
429 | Rate limited, upstream | Back off and retry. The service already retries HubSpot's own limits internally |
5xx | Upstream or service failure | Retry once with backoff, then surface it. Do not resubmit an application blindly — check for the duplicate response first |
200 with { duplicate: true } is a success, not an error. Handle it before your generic error path.
Limits and bounds#
Every bound is stated so a silently-truncated result cannot read as a complete one.
| Bound | Value | What happens at the edge |
|---|---|---|
| Posting listing | 1,000 postings | Pagination stops. Not a realistic ceiling for a careers site |
| Candidate pool for a match run | 2,000 contacts | candidatePoolTruncated: true in the response |
| Candidate pool filter | Contacts marked open to work, where the portal has that property | Portals that have not migrated their schema score the whole contact base up to the bound — see ats-setup.md |
| Postings scored per candidate run | 500 live postings | The default search bound |
match_details_json | 65,000 characters | Truncated on write |
| HubSpot rate limits | Upstream | Retried internally on 429 and 5xx, three attempts, honouring Retry-After |
What does not exist yet#
Stated plainly, because each one is something an integrator would otherwise plan around and discover late.
- No per-integrator credential. There is no API key, client id or service account for a third-party system. The public surface is portal-gated and the CRM surface is not designed for outside callers. A system that needs ATS data reads it from the portal through HubSpot's own APIs.
- No outbound webhooks. ATS does not push events anywhere. HubSpot workflows are the event mechanism — every state change ATS makes is a property write, which a workflow can enrol on.
- No resume parsing.
POST /crm/document/:id/reparsequeues a document and there is no parser behind it. The endpoint exists so the surface is ready; it does not extract anything today. - No public read of a single application. By design. The status-link flow is the only candidate-facing read, and it returns the reduced shape.
- No batch match endpoint.
/crm/match/runranks one posting against many candidates or one candidate against many postings. Scoring many pairs means many calls, and at that volume the workflow action is the better instrument.
Change policy#
- The endpoint index is generated from the service's policy manifest and checked on every commit, in both repositories. It cannot drift.
- The public embed contract — the four careers calls and their request shapes — is the stable part. Additive fields may appear; existing ones will not change meaning.
match_details_json's shape is stable in its top-level keys and its per-dimensionscore/weight/included. Per-dimension diagnostic fields may gain entries.- The CRM surface is internal and may change without notice. Build against it only if we have agreed to that with you.
- Everything else carries the
verified:date at the top of this page and no stronger guarantee.