How to Prepare ATS Data for AI Models and RAG with
·
To build AI features on ATS data with Unified.to, read jobs, candidates, applications, interviews and documents from each customer's ATS through Unified.to's ATS API, then assemble training rows, labels or RAG chunks in your own pipeline. Unified.to reads the ATS at request time and keeps no customer records at rest, so the dataset, feature store and embeddings all live in your infrastructure. This guide is for teams building candidate-job matching, ranking and recruiting assistants.
The hard part is data, not the model.
Across ATS systems, the same concepts are represented inconsistently:
- Resumes may be downloadable in one system and unavailable in another.
- Skills might exist in a schema but be empty for most integrations.
- Outcome fields (hire, reject, offer acceptance) are often missing or partially recorded.
- Stage progression history is usually not available as an event timeline.
Unified.to's ATS API gives you standard objects and consistent retrieval semantics. It does not invent missing fields, reconstruct stage history, or guarantee that any single integration returns complete outcomes.
What does preparing ATS data for AI cover?
This guide covers building an AI-ready dataset and a retraining loop from ATS objects.
We will:
- Build a dataset from standard ATS objects (jobs, candidates, applications, interviews, documents)
- Extract structured and unstructured features safely
- Construct labels when the integration returns them
- Implement incremental refresh for continuous training
We will not:
- Assume resumes are always retrievable
- Assume skills are always populated
- Assume hires/rejections/offers are universally available
- Claim stage transition history exists as a timeline
What goes into an ATS training dataset?
1) Inputs (features)
- Job requirements and context (
AtsJob) - Candidate qualifications (
AtsCandidate) - Application context and answers (
AtsApplication.answers) - Interview cadence and status (
AtsInterview) - Resume text, when available (
AtsDocument)
2) Relationships (joins)
- Candidates and jobs are linked through applications:
AtsApplication.candidate_idAtsApplication.job_id
3) Outcomes (labels)
When available:
hired_atrejected_atoffers[](nested inside application, and integration-dependent)
If these are missing for an integration, you cannot pretend they exist. Train on what you have or use alternative labels (for example, 'advanced to interview stage' based on current status, if your product accepts that limitation).
Objects you'll use
Jobs (AtsJob)
Useful fields:
idnamedescriptionstatusminimum_experience_yearsminimum_degreeemployment_typeremotequestions[]metadata[]
Note: skills[] exists in the common schema but is not reliably populated by most ATS integrations.
Candidates (AtsCandidate)
Useful fields:
idfirst_name,last_nametitlecompany_nameexperiences[]education[]tags[]metadata[]link_urls[]
Note: skills[] is in the schema but comes back on 44 of 141 candidate integrations (as of 19 October 2026).
Applications (AtsApplication)
Useful fields:
idcandidate_idjob_idstatus(current standard stage)original_status(raw stage from the integration)applied_atanswers[]rejected_at(integration-dependent)hired_at(integration-dependent)offers[](integration-dependent, nested, slow when present)
Interviews (AtsInterview)
Useful fields:
application_idstatusstart_at,end_atuser_ids[]
Documents (AtsDocument)
Useful fields:
idtype(RESUME,COVER_LETTER, etc.)filenamedocument_url(expires after 1 hour)document_data(rarely returned on read; integration-dependent)candidate_id,application_id,job_id
Important constraints:
document_urlis not returned by all integrations.- Some integrations return document metadata only (no URL, no base64).
- Some integrations do not support the document object at all.
type: RESUMEexists but resume classification is not guaranteed to be consistent across integrations.
Step 1: Build your base dataset from jobs, candidates, and applications
A safe training dataset starts with a clean join table:
- candidate ↔ application ↔ job
Field names in this guide are the REST names (snake_case). The TypeScript SDK uses camelCase for request parameters and returned fields: updated_gte → updatedGte, candidate_id → candidateId, hired_at → hiredAt, document_url → documentUrl.
Fetch open jobs (or all jobs)
The status filter is applied server-side on only 21 of 152 job integrations (as of 19 October 2026); elsewhere every job comes back, so the code below also checks status client-side. Each loop stops on an empty page or a page shorter than limit.
import { UnifiedTo } from '@unified-api/typescript-sdk';
const sdk = new UnifiedTo({
security: { jwt: process.env.UNIFIED_API_KEY! },
});
async function listOpenJobs(connectionId: string) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const page = await sdk.ats.listAtsJobs({
connectionId,
status: 'OPEN', // honoured server-side on some integrations only
limit,
offset,
sort: 'updated_at',
order: 'asc',
});
if (!page || page.length === 0) break;
out.push(...page.filter((job) => job.status === 'OPEN'));
if (page.length < limit) break;
offset += limit;
}
return out;
}
Fetch candidates
async function listCandidates(connectionId: string) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const page = await sdk.ats.listAtsCandidates({
connectionId,
limit,
offset,
sort: 'updated_at',
order: 'asc',
});
if (!page || page.length === 0) break;
out.push(...page);
if (page.length < limit) break;
offset += limit;
}
return out;
}
Fetch applications (join layer)
On 15 of 125 application integrations, listing applications requires a filter (as of 19 October 2026): job_id or candidate_id, depending on the integration. There, pass one through filter and call the function once per job or candidate.
async function listApplications(
connectionId: string,
filter: { jobId?: string; candidateId?: string } = {},
) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const page = await sdk.ats.listAtsApplications({
connectionId,
...filter,
limit,
offset,
sort: 'updated_at',
order: 'asc',
});
if (!page || page.length === 0) break;
out.push(...page);
if (page.length < limit) break;
offset += limit;
}
return out;
}
Join locally
Store everything in your database and build:
candidates_by_idjobs_by_idapplications_by_id
Then create training rows by joining each application:
function buildTrainingRows(applications: any[], candidatesById: Map<string, any>, jobsById: Map<string, any>) {
const rows = [];
for (const app of applications) {
const candidate = app.candidateId ? candidatesById.get(app.candidateId) : null;
const job = app.jobId ? jobsById.get(app.jobId) : null;
if (!candidate || !job) continue;
rows.push({ application: app, candidate, job });
}
return rows;
}
This gives you a stable foundation for feature engineering and labelling.
Step 2: Feature engineering without relying on fields that aren't reliably populated
Which structured signals are most often available?
Fields that come back on more integrations (as of 19 October 2026): job description, candidate title and company, experiences (54 of 141 candidate integrations), education (60), application answers (38 of 125) and metadata where your customers use it.
- Job description text (
job.description) - Candidate experiences (
candidate.experiences[]) - Candidate education (
candidate.education[]) - Candidate title + company name (
candidate.title,candidate.company_name) - Application answers (
application.answers[]) - Metadata (
metadata[]) when your customers use it consistently
Treat skills[] as optional enrichment
Even though skills[] exists in the common schema for candidates and jobs, it is not universally returned. It comes back on 44 of 141 candidate integrations and 15 of 152 job integrations (as of 19 October 2026). Do not build your model assuming it exists.
Step 3: Resume ingestion for embeddings (only when the integration supports it)
There is no universal 'resume always available' assumption you can make.
What to do instead
- List documents for a candidate or application
- Attempt to retrieve
document_url(and/ordocument_datawhen available) - Download within the hour the URL is valid
- Parse text → generate embeddings → store in your own system
List documents (candidate-level example). As of 19 October 2026, candidate_id is a document filter on 54 of 69 integrations and application_id on 20; ten integrations accept application_id but not candidate_id, and seven of them, including Greenhouse and Workable, require it, so pass { applicationId } there.
async function listDocuments(
connectionId: string,
filter: { candidateId?: string; applicationId?: string },
) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const page = await sdk.ats.listAtsDocuments({
connectionId,
...filter, // e.g. { candidateId } or { applicationId }
limit,
offset,
sort: 'updated_at',
order: 'asc',
});
if (!page || page.length === 0) break;
out.push(...page);
if (page.length < limit) break;
offset += limit;
}
return out;
}
Then, for each document:
- If
document_urlexists, download immediately. - If
document_datais present on read (integration-dependent), decode base64. - If neither is present, treat resume ingestion as unavailable for that integration.
Also: type: RESUME is a valid enum, but documentation does not guarantee every integration uses it consistently. Treat it as a useful hint, not a guarantee.
For the embedding and vector-store code, see ATS to Vector DB.
Step 4: Labels and outcomes (train on what you can actually observe)
The standard application schema includes:
hired_atrejected_atoffers[]withaccepted_at
But integrations differ widely:
- Some don't return
hired_atat all. - Some don't return
rejected_at(or return onlyrejected_reason). offers[]is only supported by certain integrations.- When offers are supported, they are often marked as slow fields.
offers[].accepted_atis not guaranteed to be populated even when offers exist.
Practical labelling strategy
Build labels per-integration capability and treat missing values as normal.
Example label logic:
function labelFromApplication(app: any) {
if (app.hiredAt) return 'HIRED';
if (app.rejectedAt) return 'REJECTED';
// Optional: if offers exist and acceptedAt exists, treat as HIRED-like
if (Array.isArray(app.offers) && app.offers.some((o: any) => o.acceptedAt)) return 'OFFER_ACCEPTED';
return 'IN_PROGRESS';
}
Then store:
- label
- label_source (which field produced it)
- missingness flags (e.g.,
has_hired_at,has_rejected_at,has_offers)
This makes model evaluation honest and prevents silent bias.
Step 5: Do not assume stage history exists
Unified.to returns:
AtsApplication.status(current standard stage)AtsApplication.original_status(raw stage from the integration)AtsApplicationStatusviaGET /ats/{connection_id}/applicationstatus(stage vocabulary)
It does not document:
- an ordered stage progression model
- a stage transition timeline endpoint
- historical stage transitions
So 'sequence modelling' based on transitions is not something you can claim as universally supported. If you need detailed stage history, you must rely on the integration's native data via passthrough (if the integration returns it), or accept that you're training on current state + timestamps.
Step 6: Dataset scale and sync strategy for training
Pagination constraints
ATS list endpoints paginate with:
limit(default 100)offset(0-based)
Documentation indicates:
limitdefaults to 100; request 100 or fewer per call- no total record limit is documented
What this implies for training
For any non-trivial dataset, you should:
- Sync into your own database (don't train directly off live list calls)
- Use incremental updates and/or webhooks to keep the dataset current
- Train from your DB snapshots
Before you store records, request only the fields the model needs (the fields parameter), drop direct identifiers such as names and emails from training rows, and confirm with your customer that candidates' data may be used for model training.
Step 7: Incremental refresh and retraining loop
On supported integrations, ATS list endpoints accept updated_gte (applications on 61 of 125 integrations, candidates on 74 of 141, jobs on 63 of 152, as of 19 October 2026). Where it isn't offered, re-read the object in full on a schedule.
So a safe retraining loop looks like:
- Initial backfill (paginate until exhaustion)
- Store a watermark per object type (last
updated_at) - Periodically fetch deltas using
updated_gte - Update your DB
- Recompute features for changed entities
- Retrain or refresh your model on schedule (or when enough data changes)
Example incremental read of applications:
async function listUpdatedApplications(connectionId: string, updatedSince: string) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const page = await sdk.ats.listAtsApplications({
connectionId,
updatedGte: updatedSince,
sort: 'updated_at',
order: 'asc',
limit,
offset,
});
if (!page || page.length === 0) break;
out.push(...page);
if (page.length < limit) break;
offset += limit;
}
return out;
}
Change events exist on supported integrations (candidate events on 92 ATS integrations, application events on 78, as of 20 October 2026), and:
- filter keys vary by integration and object
- filters work on virtual webhooks and, for many integrations, native webhooks
With events, you re-read only what changed. Virtual webhooks check at an interval you set, as often as every minute on paid plans.
Step 8: What to surface as 'AI insights' (without overclaiming)
If you're training models, your product outputs should map to observable data:
- Candidate-job match score (based on job description + experience/resume text when available)
- Candidate ranking for a job
- Similar candidate retrieval (embedding search)
- Pipeline conversion insights (only if outcomes exist)
- Suggested next actions (based on application status + interview cadence)
Be explicit in UX about missing data:
- 'Resume unavailable for this ATS integration'
- 'Offer outcomes not returned by this integration'
- 'Hire timestamp not present'
That transparency prevents trust issues.
What makes an ATS training set reliable?
Training AI on ATS data is viable when you treat ATS systems as they are: incomplete, inconsistent, and integration-dependent.
Unified.to's ATS API gives you the right foundation:
- one schema for candidates, jobs, applications, interviews, and documents
- one pagination model, with incremental reads where the integration supports
updated_gte - change events on supported integrations
From there, the difference between a useful model and a fragile one is whether your pipeline is honest about missing data, builds labels defensibly, and stores training-ready datasets in your own system.
Related: How to Match Candidates to Jobs Using ATS Data · How to Build a RAG Pipeline for Live SaaS Data · How to Track the Candidate Journey