Unified.to
All articles

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:

  1. Candidates (AtsCandidate)
  2. Jobs (AtsJob)
  3. 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:

  • id
  • first_name, last_name
  • emails[]
  • origin
  • external_identifier
  • company_id
  • metadata[] (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:

  • id
  • name
  • status (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:

  • id
  • candidate_id
  • job_id
  • status (normalized enum)
  • original_status (the raw vendor label)
  • applied_at
  • rejected_at
  • hired_at
  • offers[]

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:

  1. Perform an initial full load.
  2. Store the latest updated_at processed.
  3. 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.

  • id is unique per connection.
  • Multiple connections create separate identity namespaces.
  • A candidate may have multiple applications.
  • ATS systems may contain duplicate candidates.
  • external_identifier can 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.

Start your 30-day free trial or book a demo.

All articles