Unified.to
All articles

How to Ingest Jobs and Candidates from ATS Systems with

·

To ingest ATS data with Unified.to's ATS API, list candidates, jobs and applications separately for each connection, store them with their IDs, and keep them current with updated_gte reads or webhooks on supported integrations. This guide is for teams loading ATS data into their own database. It sounds simple: read candidates, read jobs, store them, 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 return 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.

What do you ingest from an ATS?

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: How do you 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: How do you 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: How do you ingest applications?

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 one pipeline view across integrations that map it, and original_status keeps the ATS's own stage name.

Do not flatten applications into candidate records. Applications are separate lifecycle instances.

Step 4: How do you model relationships?

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: How do you read incrementally with updated_gte?

Full reloads don't scale. Many ATS integrations accept updated_gte on candidates, jobs and applications (each integration's page lists its list options):

  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 reads the source ATS directly: you get what the ATS holds now, not a copy that may be hours behind a sync schedule. Where updated_gte isn't supported, reload the object in full or use webhooks.

Can you ingest with webhooks instead?

To avoid scheduled reads, subscribe to webhooks on supported integrations, for 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 exist for candidates, applications, jobs, companies, interviews and scorecards, almost only on integrations with native webhooks, such as Greenhouse, Lever, SmartRecruiters and Teamtailor for candidates. Native webhook behavior varies by provider; virtual webhooks detect created and updated records at an interval you set (as often as every minute on paid plans) and deliver them as events.

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.

Where an integration supports updated_gte, scheduled incremental reads are enough for most ingestion jobs.

If you'd rather not run the ingestion job yourself, Database Sync (Grow, Pro and Scale) writes candidate, job and application changes into your MongoDB, MySQL, Postgres, MSSQL, MariaDB, Supabase or Snowflake database for objects with native or virtual webhooks.

How do you handle duplicates?

Ingestion reveals duplicates. It doesn't remove 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: usually email matching, external-identifier matching or candidate-plus-job matching. See How to Deduplicate and Visualize Talent Pools.

How do you handle custom fields?

ATS providers often include custom fields beyond the normalized objects. Unified.to returns them in metadata[] on supported objects, and the rest of the vendor payload in raw. 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 doesn't Unified.to do during ingestion?

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 returns what the ATS records, in one model.

How do you start?

Ingesting ATS data correctly means keeping candidates, jobs, and applications separate, and using incremental updates to keep them current. Unified.to's ATS API gives you one model for candidates, jobs and applications, read from the source at request time, so you ingest across ATS integrations without per-vendor code.

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