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 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
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.
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.
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.
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
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
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
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
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:
- Store the last processed
updated_atper object. - Re-fetch with
updated_gte. - 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 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.