How to Ingest Jobs and Candidates from ATS Systems with Unified's ATS API
February 2, 2026
Updated June 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.to's ATS API in a way that's technically sound and portable across providers.
The ingestion mental model
There are three primary entities 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 — active applicants, past applicants, and general prospects not tied to a job.
GET /ats/{connection_id}/candidate
Node SDK:
import { UnifiedTo } from '@unified-api/typescript-sdk';
const sdk = new UnifiedTo({
security: { jwt: process.env.UNIFIED_API_KEY! },
});
async function ingestCandidates(connectionId: string) {
return await sdk.ats.listAtsCandidates({
connectionId,
limit: 100,
offset: 0,
sort: 'updated_at',
order: 'asc',
});
}
Key AtsCandidate fields:
idfirst_name,last_nameemails[]originexternal_identifiercompany_idmetadata[](custom fields)
Treat id as the stable primary key within a connection. Do not assume identity is consistent across connections.
Step 2: Ingest jobs
Jobs provide the context for applications and pipeline reporting.
GET /ats/{connection_id}/job
async function ingestJobs(connectionId: string) {
return await sdk.ats.listAtsJobs({
connectionId,
limit: 100,
offset: 0,
sort: 'updated_at',
order: 'asc',
});
}
Key AtsJob fields:
idnamestatus(OPEN,CLOSED, etc.)groups[]public_job_urls[]openings[]company_id
Store jobs separately from candidates. Applications connect them.
Step 3: Ingest applications (the join layer)
Applications link candidates to jobs and define pipeline state.
GET /ats/{connection_id}/application
async function ingestApplications(connectionId: string) {
return await sdk.ats.listAtsApplications({
connectionId,
limit: 100,
offset: 0,
sort: 'updated_at',
order: 'asc',
});
}
Important AtsApplication fields:
idcandidate_idjob_idstatus(normalized enum)original_status(the raw vendor label)applied_atrejected_athired_atoffers[]
This object tells you who applied to which job, what stage they're in, when key transitions occurred, and whether offers were sent or accepted. The normalized status enum gives you a consistent pipeline view across every ATS, while original_status preserves the source-of-truth value.
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
- 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 don't scale. All ATS list endpoints support updated_gte:
- Perform an initial full load.
- Store the latest
updated_atprocessed. - On subsequent runs, fetch only records updated since that timestamp.
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,
});
}
Apply the same pattern to jobs and applications. Because Unified.to is pass-through, an updated_gte query hits the source system directly — you're reading what the ATS holds now, not a copy that may already be hours behind a sync schedule.
Optional: webhook-based ingestion
If you need near-source-fresh ingestion without polling, Unified.to supports webhooks for ATS objects, including ats_candidate, ats_application, ats_job, ats_activity, ats_document, ats_interview, ats_scorecard, ats_company, and ats_applicationstatus.
Event types are created and updated across these objects; deleted events are emitted for a subset — candidates, applications, jobs, companies, and scorecards — while delivery of deletions also depends on what the provider exposes. Native webhook behavior varies by provider; virtual webhooks deliver created and updated records based on polling at a configured interval.
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 don't accept filters.
Polling via updated_gte remains universally supported and is sufficient for most ingestion workflows.
Modeling identity and deduplication
Ingestion exposes duplicates. It doesn't eliminate them.
idis unique per connection.- Multiple connections create separate identity namespaces.
- A candidate may have multiple applications.
- ATS systems may contain duplicate candidates.
external_identifiercan assist correlation if consistently populated.
Unified.to does not merge identities across connections. Deduplication belongs in your application layer — typically email-based matching, external-identifier matching, or candidate-plus-job dedupe.
Handling custom fields
ATS providers often include custom fields beyond the normalized objects. Unified.to exposes them through metadata[] on supported objects. If your product depends on custom attributes, persist the metadata[] array alongside the normalized fields so custom values can be rendered or processed without depending on provider-specific schemas.
What Unified.to does not do
Unified.to does not:
- Merge duplicate candidates across systems
- Reconstruct deleted records unless the ATS provides them
- Infer relationships not present in the data
- Enforce identical field support across all integrations
It exposes what the ATS records, consistently.
Ingesting correctly
Ingesting ATS data correctly means keeping candidates, jobs, and applications separate, and using incremental updates to keep them current. Unified.to's ATS API provides normalized objects and consistent list semantics, fetched live from the source, so you ingest jobs and candidates across providers without per-vendor logic.
From there, talent-pool visibility becomes a modeling problem, not an integration problem.