How to Ingest Jobs and Candidates from ATS Systems with Unified's ATS API
February 2, 2026
Ingesting ATS data sounds simple: pull candidates, pull jobs, store them, and move on.
In practice, it's rarely that clean.
Every ATS models candidates, applications, and jobs differently. Some systems treat prospects and applicants the same. Others separate them. Some expose structured offer and interview data. Others bury critical information in activity logs. Even basic fields like status, source, or ownership vary across providers.
If your product depends on accurate talent pool visibility, ingestion has to be:
- Consistent across ATS systems
- Incremental and efficient
- Explicit about identity and relationships
- Grounded in what the ATS actually records
This guide shows how to ingest jobs and candidates using Unified's ATS API in a way that is technically sound and portable across providers.
The ingestion mental model
There are three primary entities you need to ingest:
- Candidates (
AtsCandidate) - Jobs (
AtsJob) - Applications (
AtsApplication)
Applications connect candidates to jobs. They carry the state and lifecycle of a candidate within a specific hiring pipeline.
A correct ingestion strategy pulls these objects independently and models their relationships explicitly.
Step 1: Ingest Candidates
Candidates represent people stored in the ATS. They may include:
- Active applicants
- Past applicants
- General prospects not tied to a job
To ingest candidates:
GET /ats/{connection_id}/candidate
Node SDK example:
import { UnifiedTo } from '@unified-api/typescript-sdk';
const sdk = new UnifiedTo({
security: { jwt: process.env.UNIFIED_API_KEY! },
});
async function ingestCandidates(connectionId: string) {
const results = await sdk.ats.listAtsCandidates({
connectionId,
limit: 100,
offset: 0,
sort: 'updated_at',
order: 'asc',
fields: '',
raw: '',
});
return results;
}
Key fields in AtsCandidate:
idfirst_name,last_nameemails[]originexternal_identifiercompany_idmetadata[](custom fields)
Treat candidate_id as the stable primary key within a connection.
Do not assume cross-connection identity consistency.
Step 2: Ingest Jobs
Jobs provide the context for applications and pipeline reporting.
To ingest jobs:
GET /ats/{connection_id}/job
SDK example:
async function ingestJobs(connectionId: string) {
const results = await sdk.ats.listAtsJobs({
connectionId,
limit: 100,
offset: 0,
sort: 'updated_at',
order: 'asc',
fields: '',
raw: '',
});
return results;
}
Key AtsJob fields:
idnamestatus(OPEN,CLOSED, etc.)groups[]public_job_urls[]openings[]company_id
Jobs should be stored separately from candidates. Applications will connect them.
Step 3: Ingest Applications (the join layer)
Applications link candidates to jobs and define pipeline state.
To ingest applications:
GET /ats/{connection_id}/application
SDK example:
async function ingestApplications(connectionId: string) {
const results = await sdk.ats.listAtsApplications({
connectionId,
limit: 100,
offset: 0,
sort: 'updated_at',
order: 'asc',
fields: '',
raw: '',
});
return results;
}
Important AtsApplication fields:
idcandidate_idjob_idstatus(normalized enum)original_statusapplied_atrejected_athired_atoffers[]
This object tells you:
- Who applied to which job
- What stage they're currently in
- When key transitions occurred
- Whether offers were sent or accepted
Do not flatten applications into candidate records. Applications are separate lifecycle instances.
Step 4: Model Relationships Explicitly
After ingestion, your internal model should reflect:
Candidate
↳ Applications[]
↳ Job
Important implications:
- A candidate can have multiple applications.
- A job can have many candidates.
- Applications carry stage and lifecycle timestamps.
Keep these entities distinct to avoid corrupting reporting logic.
Step 5: Use Incremental Sync with updated_gte
Full resyncs do not scale.
All ATS list endpoints support:
updated_gte
A safe ingestion pattern:
- Perform initial full load.
- Store the latest
updated_atprocessed. - On subsequent runs, fetch only records updated since that timestamp.
Example:
async function ingestUpdatedCandidates(
connectionId: string,
updatedSince: string
) {
return await sdk.ats.listAtsCandidates({
connectionId,
updated_gte: updatedSince,
sort: 'updated_at',
order: 'asc',
limit: 100,
offset: 0,
fields: '',
raw: '',
});
}
Apply the same pattern to:
- Jobs
- Applications
This keeps ingestion predictable and efficient.
Optional: Move to Webhook-Based Ingestion
If near-real-time ingestion is required, Unified supports webhooks for ATS objects, including:
ats_candidateats_applicationats_jobats_activityats_documentats_interviewats_scorecardats_companyats_applicationstatus
Webhooks support the following event types:
createdupdateddeleted
Virtual webhooks commonly deliver created and updated records based on polling at a configured interval. Native webhook behavior, including whether deletion events are delivered, depends on the provider.
Filters are supported for virtual webhooks and must match the object's list-endpoint parameters (for example, parameters ending in _id or type). Filter availability varies by integration and object. Native webhooks do not accept filters.
Polling via updated_gte remains universally supported and sufficient for most ingestion workflows.
Modeling Identity and Deduplication
Ingestion exposes duplicates. It does not eliminate them.
Important considerations:
candidate_idis unique per connection.- Multiple connections create separate identity namespaces.
- A candidate may have multiple applications.
- ATS systems may contain duplicate candidates.
external_identifiermay assist in correlation if consistently populated.
Unified does not merge identities across connections. Deduplication logic belongs in your application layer.
Common approaches:
- Email-based matching
- External identifier matching
- Candidate + job application dedupe
Handling Custom Fields
ATS providers often include custom fields beyond normalized objects.
Unified exposes custom fields through metadata[] on supported objects.
If your product depends on custom attributes, ingest and persist the metadata[] array alongside normalized fields so that custom values can be rendered or processed without depending on provider-specific schemas.
What Unified Does Not Do
Unified does not:
- Merge duplicate candidates across systems
- Reconstruct deleted records unless provided by the ATS
- Infer relationships not present in the data
- Enforce identical field support across all integrations
It exposes what the ATS records, consistently.
Closing Thoughts
Ingesting ATS data correctly means separating:
- Candidates
- Jobs
- Applications
And using incremental updates to keep them current.
Unified's ATS API provides normalized objects and consistent list semantics so you can ingest jobs and candidates across providers without per-vendor logic.
From there, talent pool visibility becomes a modeling problem, not an integration problem.