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#

SurfacePathWhat is authenticatedWho calls it
Public embed/apps/ats/*The portal, and its licence — not the callerThe careers site, from a visitor's browser
Candidate self-service/apps/ats/fetch-my-applications, /withdraw-applicationA signed status-link token, which is the identityA candidate holding an emailed link
Recruiter and automation/apps/ats/crm/*, /apps/ats/wf/*A HubSpot-signed request carrying a proven portal and user (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 individual user, cryptographically. Cards call it through the UI-extension SDK's hubspot.fetch(), and HubSpot signs every production call of that kind with the app's OAuth client secret, writing the caller's portal and user id into the signed request. The backend validates that signature and takes the identity from it — never from anything the card's own code sent. A request that arrives without a valid signature is refused, so a portal id copied out of a page source buys an attacker nothing. This also means recruiter actions carry real attribution: the "updated by" on a record is the user HubSpot vouched for, not a self-declared value.

Two honest limits an IT reviewer should have in writing. A captured request can be replayed for up to five minutes, the skew window HubSpot's signature scheme allows. And the gate has a documented emergency setting that drops the surface back to portal-and-licence checking; it is off, and turning it on is a deliberate operator action that gets recorded.

The workflow surface is separately signed: those are genuine HubSpot-signed webhooks, verified per request.

This paragraph said the opposite until 2026-08-15 — that HubSpot did not sign card-initiated calls and portal-level binding was the strongest available. That was wrong, and had never been checked against a real request. The signature gate has been enforcing since 2026-08-12.

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.

45 endpoints — 8 public, 2 customer, 30 admin, 5 workflow.

EndpointSurfaceAuth required
GET /apps/ats/jobs.xmlPublicPortal + licence
GET /apps/ats/license-statusPublicNone
POST /apps/ats/create-applicationPublicPortal + licence
POST /apps/ats/fetch-posting-detailsPublicPortal + licence
POST /apps/ats/fetch-postingsPublicPortal + licence
POST /apps/ats/request-status-linkPublicPortal + licence
POST /apps/ats/subscribe-alertsPublicPortal + licence
POST /apps/ats/upload-documentPublicPortal + licence
POST /apps/ats/fetch-my-applicationsCustomerA signed token, which is itself the identity
POST /apps/ats/withdraw-applicationCustomerA signed token, which is itself the identity
GET /apps/ats/crm/app-settingAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/application/:id/bundleAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/applications/boardAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/candidate/:contactId/overviewAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/dashboard/summaryAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/document/:idAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/interview/:idAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/interviews/agendaAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/portal-stateAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/posting/:id/summaryAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/postings/listAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/reports/:reportAdminAuthenticated portal user, from a CRM card
GET /apps/ats/crm/settingsAdminAuthenticated portal user, from a CRM card
POST /apps/ats/admin/migrate-schemaAdminPortal + shared secret
POST /apps/ats/crm/admin/upgrade-tierAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/app-settingAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/application/:id/stageAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/cv-parse/testAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/document/:id/reparseAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/geocode/backfillAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/interviewAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/interview/:id/outcomeAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/match/persistAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/match/runAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/posting/:id/closeAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/posting/:id/geocodeAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/posting/:id/publishAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/posting/createAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/settingsAdminAuthenticated portal user, from a CRM card
POST /apps/ats/crm/travel/lookupAdminAuthenticated portal user, from a CRM card
POST /apps/ats/wf/close-postingWorkflowHubSpot signature (v3)
POST /apps/ats/wf/create-interviewWorkflowHubSpot signature (v3)
POST /apps/ats/wf/parse-resumeWorkflowHubSpot signature (v3)
POST /apps/ats/wf/recalculate-matchWorkflowHubSpot signature (v3)
POST /apps/ats/wf/set-stageWorkflowHubSpot signature (v3)

What that table includes, and what it means. The /crm/* and /wf/* sub-routers are separate Express routers mounted ahead of the portal gate, each carrying its own check — /crm/* a licensed-install check, /wf/* a HubSpot v3 signature. They are asserted by the coverage test as of 2026-08-09; before that the extractor could not see into a mounted router, so they were live and undeclared.

Being listed is not an invitation to call them. 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. Read the Surface column as what proves the caller, not who may call — a route marked Public is one whose gate is the portal install plus licence, which is why an in-product recruiter surface can carry that label.

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.

StepCallNotes
ListPOST /fetch-postingsLive 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
DetailPOST /fetch-posting-details{ id, isSlug }. Resolves by slug or by public_posting_id, trying the hinted one first
ApplyPOST /create-application{ publicPostingId, applicant: { email, … }, contactId?, source? }
Attach a CVPOST /upload-documentMultipart. 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 200 with { 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, archived and hidden are declined with a message meant for the candidate.
  • contactId is 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 on contactId to 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.

FieldFrom
title · city · categoryPosting title, public location label, department
urlThe careers base URL joined to the posting's slug
referencenumberpublic_posting_id — stable across edits, so a board updates rather than duplicates
datefirst_published_at
company · publisherThe Employer Name setting, falling back to the careers hostname
descriptionThe four public content fields, flattened to text
salaryOnly what salary_visibility permits — see below
jobtype · remotetypeEmployment 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_visibility is an editorial decision per posting, and a feed is a republication into a channel nobody reviews. hidden means hidden here too; minimum, maximum and text_only each render exactly what the public job page would.
  • A posting marked noindex is 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#

  1. The candidate submits an email to POST /request-status-link with the URL of your status page. The response is always the same acknowledgement.
  2. If they have applications on file, they receive an email with ?token=… appended to that page.
  3. Your page calls POST /fetch-my-applications with the token. An invalid or expired token gets 401 and the message to request a new link — treat it as a prompt, not a failure.
  4. POST /withdraw-application withdraws 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 }
ModesourceIdWhat it does
posting_to_candidates (default)A job posting record idScores the candidate pool against that posting and returns the top limit
candidate_to_postingsA contact record idScores 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:

  • candidatePoolSize is how many contacts were actually scored, after filtering.
  • candidatePoolTruncated warns 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.
  • totalScored versus belowThreshold is how many were removed by the display minimums rather than by their score. A topMatches shorter than limit is usually this, not a shortage of candidates.
  • executionMs is 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.

Inputpersist — set it to false for a scored-but-not-stored run
Outputsmatch_score, gate_triggered — both usable in later workflow branches
On failureFAIL_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:

StatusMeansWhat your integration should do
400A required field is missing or unusableFix the call. Never retry unchanged
401The status-link token is invalid or expiredPrompt for a new link. This is an ordinary state, not an error to report
403The portal is not installed, or its licence is inactiveStop. Retrying will not help; this needs an operator
404The posting does not exist, or is not publicTreat as gone. Do not fall back to listing everything
409The posting no longer accepts applications, or a required object is not installed on the portalShow the candidate the message; escalate the install case to an operator
429Rate limited, upstreamBack off and retry. The service already retries HubSpot's own limits internally
5xxUpstream or service failureRetry 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.

BoundValueWhat happens at the edge
Posting listing1,000 postingsPagination stops. Not a realistic ceiling for a careers site
Candidate pool for a match run2,000 contactscandidatePoolTruncated: true in the response
Candidate pool filterContacts marked open to work, where the portal has that propertyPortals that have not migrated their schema score the whole contact base up to the bound — see ats-setup.md
Postings scored per candidate run500 live postingsThe default search bound
match_details_json65,000 charactersTruncated on write
HubSpot rate limitsUpstreamRetried 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/reparse returns 409 with the reason, and changes nothing. Uploaded documents are stored with parse_status: 'skipped'; not_started is reserved for a parse that is genuinely coming, so a status read tells an integrator which of the two it is looking at. Until 2026-08-11 the endpoint returned 200 and set parse_status: 'queued' — a state nothing consumed — so anything built to poll for parsed would have waited forever.
  • 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/run ranks 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-dimension score/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.