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

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
POST /apps/ats/admin/migrate-schemaAdminPortal + 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.

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 queues 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/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.