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_idstatus(normalized enum)original_status(ATS-native value)applied_at,rejected_at,hired_atrejected_reasonoffers[](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:
statusoriginal_statusdescription
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_idstatus(SCHEDULED,AWAITING_FEEDBACK,COMPLETE, etc.)start_at,end_atuser_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_idtype(NOTE,TASK,EMAIL)title,descriptiondocument_ids[]created_at
Activities explain what happened without implying state transitions.
Scorecard (AtsScorecard)
Structured feedback.
Key fields:
application_id,interview_idinterviewer_idrecommendationcommentquestions[]
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.)filenamedocument_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:
namestatus(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:
- Store the last processed
updated_atper object - Re-fetch with
updated_gte - 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.