---
title: "How to Track the Candidate Journey"
img: https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_track_the_candidate_journey-icon.png
date: 2026-02-02T17:20:00.000Z
tag: Product
description: "\"Candidate journey\" sounds simple until you support more than one ATS."
url: "https://unified.to/blog/how_to_track_the_candidate_journey"
---

# How to Track the Candidate Journey
------
_February 2, 2026_

_Updated June 2026_


"Candidate journey" sounds simple until you support more than one ATS.


Every ATS models hiring pipelines differently. Some expose a rich set of interview stages; others collapse progress into a single status field. Some provide detailed event logs; others overwrite records in place. Even basic concepts like interviews, feedback, and documents vary in structure and availability.


For a product or platform team, this creates immediate challenges:

- What is the candidate's **current state** in the pipeline?
- What actually constitutes the "journey" — stages, interviews, communications, artifacts?
- How do you show a timeline without inferring steps that were never recorded?
- How do you keep the view accurate without full resyncs or vendor-specific logic?

Unified.to's [ATS API](/ats) takes a strict approach. It does **not** reconstruct stage history or infer transitions. Instead, it exposes **observable application state**, **explicit lifecycle timestamps**, and **linked objects** (interviews, activities, scorecards, documents) through normalized objects fetched live from the source.


This guide shows how to track the candidate journey through an ATS pipeline using only what the ATS actually records — consistently, explainably, and portably across providers.


## What "candidate journey" means here


This guide focuses on **representing** the journey, not predicting outcomes.


We will:

- Determine the current pipeline state per application
- Build a timeline from explicit timestamps and linked objects
- Attach interviews, feedback, communications, and documents
- Keep the journey current using incremental updates and [webhooks](/blog/replacing_polling_with_unified_webhooks_and_virtual_streams)

We will not:

- Reconstruct stage history from overwritten fields
- Assume all ATS systems emit the same events
- Infer transitions that were never recorded

Those decisions belong in your product logic, not the integration layer.


## The mental model: the journey lives on the application


Candidates don't move through pipelines. **Applications do.**


A single candidate can apply to multiple jobs, hold applications in different states, and accumulate different interviews, activities, and feedback per application.


A "candidate journey" is therefore a composition of candidate identity, one or more applications, job context per application, and a timeline built from observable events and timestamps.


## Core objects involved


### Application (`AtsApplication`)


The anchor for the journey. Key fields: `id`, `candidate_id`, `job_id`, `status` (normalized enum), `original_status` (ATS-native value), `applied_at`, `rejected_at`, `hired_at`, `rejected_reason`, and `offers[]` (with sent / accepted / rejected / start timestamps). This object defines the current state and the major lifecycle milestones.


### Application status (`AtsApplicationStatus`)


The normalized stage vocabulary. Key fields: `status`, `original_status`, `description`. Use it to display consistent stages while preserving vendor-specific naming.


### Interview (`AtsInterview`)


Scheduled moments in the journey. Key fields: `application_id`, `candidate_id`, `job_id`, `status`, `start_at`, `end_at`, `location`, `user_ids[]`, `external_event_xref`. Interviews turn "in interview" into something concrete and time-bound.


### Activity (`AtsActivity`)


Narrative context and communication. Key fields: `application_id`, `candidate_id`, `job_id`, `interview_id`, `type`, `title`, `description`, `document_ids[]`, `created_at`. Activities explain _what happened_ without implying state transitions.


### Scorecard (`AtsScorecard`)


Structured feedback. Key fields: `application_id`, `interview_id`, `interviewer_id`, `recommendation`, `comment`, `questions[]`. Scorecards are critical for internal "awaiting feedback" logic and post-interview state.


### Document (`AtsDocument`)


Artifacts exchanged during the journey. Key fields: `type`, `filename`, `document_url` (short-lived), `candidate_id`, `application_id`, `job_id`. Documents power candidate-facing portals and offer workflows.


### Job (`AtsJob`)


Context for the application. Key fields: `name`, `status`, `groups[]`, `openings[]`, `public_job_urls[]`. Jobs explain _where_ the candidate sits in the organization's hiring process.


_Enum values for fields like interview_ _`status`__, document_ _`type`__, and the offer sub-timestamps are illustrative below — confirm the exact strings against each object's model page before hardcoding them._


## Step 1: Fetch applications for a candidate


The journey is application-first.


```typescript
import { UnifiedTo } from '@unified-api/typescript-sdk';

const sdk = new UnifiedTo({
  security: { jwt: process.env.UNIFIED_API_KEY! },
});

async function listCandidateApplications(connectionId: string, candidateId: string) {
  const out: any[] = [];
  let offset = 0;
  const limit = 100;

  while (true) {
    const page = await sdk.ats.listAtsApplications({
      connectionId,
      limit,
      offset,
      sort: 'updated_at',
      order: 'asc',
      candidateId,
    });

    if (!page || page.length === 0) break;
    out.push(...page);
    offset += limit;
  }

  return out;
}
```


If the candidate is already known in your system, you don't need to list candidates at all.


## Step 2: Determine the current stage


Use the normalized status for logic, and the original status for display.


```typescript
function currentStage(app: any) {
  return app.status;
}

function displayStage(app: any) {
  return app.originalStatus ?? app.status;
}
```


This avoids hardcoding stage names or vendor logic.


## Step 3: Build a defensible milestone timeline


Use only timestamps the ATS explicitly recorded.


```typescript
function applicationMilestones(app: any) {
  const milestones: any[] = [];

  if (app.appliedAt) milestones.push({ type: 'APPLIED', at: app.appliedAt });

  if (Array.isArray(app.offers)) {
    for (const offer of app.offers) {
      if (offer.sentAt) milestones.push({ type: 'OFFER_SENT', at: offer.sentAt });
      if (offer.acceptedAt) milestones.push({ type: 'OFFER_ACCEPTED', at: offer.acceptedAt });
      if (offer.rejectedAt) milestones.push({ type: 'OFFER_REJECTED', at: offer.rejectedAt });
      if (offer.startAt) milestones.push({ type: 'START_DATE', at: offer.startAt });
    }
  }

  if (app.rejectedAt) {
    milestones.push({ type: 'REJECTED', at: app.rejectedAt, reason: app.rejectedReason });
  }

  if (app.hiredAt) milestones.push({ type: 'HIRED', at: app.hiredAt });

  return milestones.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime());
}
```


This produces a timeline you can defend under audit.


## Step 4: Attach interviews


```typescript
async function listApplicationInterviews(connectionId: string, applicationId: string) {
  const out: any[] = [];
  let offset = 0;
  const limit = 100;

  while (true) {
    const page = await sdk.ats.listAtsInterviews({
      connectionId,
      limit,
      offset,
      sort: 'updated_at',
      order: 'asc',
      applicationId,
    });

    if (!page || page.length === 0) break;
    out.push(...page);
    offset += limit;
  }

  return out;
}
```


Interviews add scheduled, real-world events to the journey.


## Step 5: Attach scorecards


```typescript
async function listApplicationScorecards(connectionId: string, applicationId: string) {
  const out: any[] = [];
  let offset = 0;
  const limit = 100;

  while (true) {
    const page = await sdk.ats.listAtsScorecards({
      connectionId,
      limit,
      offset,
      sort: 'updated_at',
      order: 'asc',
      applicationId,
    });

    if (!page || page.length === 0) break;
    out.push(...page);
    offset += limit;
  }

  return out;
}
```


Scorecards explain post-interview states without guessing.


## Step 6: Attach activities


```typescript
async function listApplicationActivities(connectionId: string, applicationId: string) {
  const out: any[] = [];
  let offset = 0;
  const limit = 100;

  while (true) {
    const page = await sdk.ats.listAtsActivities({
      connectionId,
      limit,
      offset,
      sort: 'updated_at',
      order: 'asc',
      applicationId,
    });

    if (!page || page.length === 0) break;
    out.push(...page);
    offset += limit;
  }

  return out;
}
```


Activities provide context, not state.


## Step 7: Attach documents


```typescript
async function listApplicationDocuments(connectionId: string, applicationId: string) {
  const out: any[] = [];
  let offset = 0;
  const limit = 100;

  while (true) {
    const page = await sdk.ats.listAtsDocuments({
      connectionId,
      limit,
      offset,
      sort: 'updated_at',
      order: 'asc',
      applicationId,
    });

    if (!page || page.length === 0) break;
    out.push(...page);
    offset += limit;
  }

  return out;
}
```


Document URLs are short-lived and should be fetched just in time.


## Step 8: Keep journeys current


All ATS list endpoints support `updated_gte`, enabling incremental refresh:

1. Store the last processed `updated_at` per object.
2. Re-fetch with `updated_gte`.
3. Recompute only the impacted applications.

Because Unified.to is pass-through, each `updated_gte` query reads the source directly rather than a synced copy that may already be behind.


For near-source-fresh updates, Unified.to supports **[virtual webhooks](/blog/unlock_real_time_data_with_virtual_webhooks)** for ATS objects. They deliver created and updated records, can be filtered using list-endpoint parameters (exact-match, scoped per integration and object), and are not available for native webhooks. Filter keys vary by integration and must be derived from each provider's Feature-Support "List Options."


## What Unified.to does not infer


Unified.to intentionally does not reconstruct stage history, guess transition timestamps, assume activities imply state changes, or enforce a single pipeline model. It exposes what the ATS records — nothing more.


## Stage is a label; the journey is what happened


Candidate journey tracking breaks when "stage" is treated as the journey.


Stage is a label. The journey is what actually happened: applications submitted, interviews scheduled, feedback recorded, documents exchanged, decisions made — each at a timestamp you can explain.


Unified.to's ATS API gives you the primitives to model that reality across ATS systems without vendor-specific logic: normalized application state, explicit lifecycle timestamps, and linked interviews, activities, scorecards, and documents. From there, the journey your product shows is a product decision — not an integration gamble.


[Start your 30-day free trial](https://app.unified.to/) or [book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified).