
# ATS — integration guide

> Load this file when: calling the ATS over HTTP, embedding the careers site, or building against the match API — `api.worksby.design/apps/ats`, the `/crm/match/run` ranking call, `match_details_json`, the `recalculate-match` workflow action, or the status-link token the candidate portal uses. How scoring works: [ats-matching.md](ats-matching.md). What the app is: [ats-reference.md](ats-reference.md).

> 🔒 **Works by Design system — not a HubSpot platform feature.** These endpoints are ours; `api.worksby.design` is our service and pointing a build at it will fail unless we installed the app on your portal. Portable regardless: the three-surface split below — a portal-authenticated public surface, a capability token that is the whole credential, and a signature-verified automation backend — is a shape worth copying for any app with a public embed and a CRM back office.

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.

<!-- routes:ats — GENERATED from routes/apps/ats/policy-manifest.js by sync-routes.mjs. Do not edit by hand. -->

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 |

<!-- /routes:ats -->

**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 `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

```text
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_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](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.

```json
{ "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`:

```json
{
  "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

```json
{ "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](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](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/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.
