---
title: "How to Ingest Jobs and Candidates from ATS Systems with"
img: https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_ingest_jobs_and_candidates_from_ats_systems_with-icon.png
date: 2026-02-02T17:20:00.000Z
updated: 2026-10-21T11:21:25.081Z
readingTime: 5
tag: Product
description: "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..."
url: "https://unified.to/blog/how_to_ingest_jobs_and_candidates_from_ats_systems_with"
---

# How to Ingest Jobs and Candidates from ATS Systems with

<iframe width="560" height="315" src="https://www.youtube.com/embed/JUYir5xRiHc" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


To ingest ATS data with [Unified.to](http://unified.to/)'s [ATS API](/ats), list candidates, jobs and applications separately for each connection, store them with their IDs, and keep them current with `updated_gte` reads or [webhooks](/blog/replacing_polling_with_event_driven_integrations_using_virtual_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.


```plain text
GET /ats/{connection_id}/candidate
```


Node SDK:


```typescript
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.


```plain text
GET /ats/{connection_id}/job
```


```typescript
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.


```plain text
GET /ats/{connection_id}/application
```


```typescript
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:


```plain text
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.

```typescript
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](http://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](/blog/what_are_virtual_webhooks_how_they_differ_from_sync_based_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](http://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](https://unified.to/blog/how_to_deduplicate_and_visualize_talent_pools_across_ats_platforms_using_unified_ats_api).


## How do you handle custom fields?


ATS providers often include custom fields beyond the normalized objects. [Unified.to](http://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](http://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](http://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](https://app.unified.to/) or [book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified).