Unified.to
All articles

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:

  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. 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:

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

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

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

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:

  1. Perform initial full load.
  2. Store the latest updated_at processed.
  3. 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_candidate
  • ats_application
  • ats_job
  • ats_activity
  • ats_document
  • ats_interview
  • ats_scorecard
  • ats_company
  • ats_applicationstatus

Webhooks support the following event types:

  • created
  • updated
  • deleted

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_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 may 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.

Start your 30-day free trial

Book a demo

All articles