Unified.to
All articles

How to Track the Candidate Journey


February 2, 2026

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

Every ATS models hiring pipelines differently. Some systems 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's ATS API 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, real-time APIs.

This guide shows how to track the candidate journey through an ATS pipeline using only what the ATS actually records — in a way that is consistent, explainable, and portable across providers.

What 'candidate journey' means here

This article 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

We will not:

  • Reconstruct stage history from overwritten fields
  • Assume all ATSs 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
  • Have applications in different states
  • 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
  • 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
  • offers[] (with sent / accepted / rejected / start timestamps)

This object defines the current state and the major lifecycle milestones.

Application Status (AtsStatus)

The normalized stage vocabulary.

Key fields:

  • status
  • original_status
  • description

Use this 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 (SCHEDULED, AWAITING_FEEDBACK, COMPLETE, etc.)
  • start_at, end_at
  • 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 (NOTE, TASK, EMAIL)
  • 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 (RESUME, OFFER_LETTER, TAKE_HOME_TEST, etc.)
  • 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 (OPEN, CLOSED)
  • groups[], openings[]
  • public_job_urls[]

Jobs explain where the candidate is in the organization's hiring process.

Step 1: Fetch applications for a candidate

The journey is application-first.

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 = [];
  let offset = 0;
  const limit = 100;

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

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

  return out;
}

If the candidate is already known in your system, you do not need to list candidates at all.

Step 2: Determine the current stage

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

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

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

This avoids hardcoding stage names or vendor logic.

Step 3: Build a defensible milestone timeline

Only use timestamps explicitly recorded by the ATS.

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

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

  if (Array.isArray(app.offers)) {
    for (const offer of app.offers) {
      if (offer.sent_at) milestones.push({ type: 'OFFER_SENT', at: offer.sent_at });
      if (offer.accepted_at) milestones.push({ type: 'OFFER_ACCEPTED', at: offer.accepted_at });
      if (offer.rejected_at) milestones.push({ type: 'OFFER_REJECTED', at: offer.rejected_at });
      if (offer.start_at) milestones.push({ type: 'START_DATE', at: offer.start_at });
    }
  }

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

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

  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

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

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

    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

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

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

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

  return out;
}

Scorecards explain post-interview states without guessing.

Step 6: Attach activities

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

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

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

  return out;
}

Activities provide context, not state.

Step 7: Attach documents

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

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

    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.

Typical pattern:

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

For near-real-time updates, Unified supports virtual webhooks for ATS objects. Webhooks:

  • Deliver created and updated records
  • Can be filtered using list-endpoint parameters
  • Are scoped per integration and object
  • Use exact-match filters
  • Are not available for native webhooks

Webhook filter keys vary by integration and must be derived from each provider's Feature-Support 'List Options'.

What Unified does not infer

Unified intentionally does not:

  • Reconstruct stage history
  • Guess transition timestamps
  • Assume activities imply state changes
  • Enforce a single pipeline model

It exposes what the ATS records — nothing more.

Closing thoughts

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 — all at timestamps you can explain.

Unified's ATS API gives you the primitives to model that reality across ATSs 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

Book a demo

All articles