How to Track the Candidate Journey Through an ATS Pipeline with
·
To track a candidate's journey across ATS platforms with Unified.to, read each application's current status and lifecycle timestamps, then attach its interviews, scorecards, activities and documents through Unified.to's ATS API. This guide is for product teams showing a pipeline timeline in a recruiting, assessment or analytics product. Unified.to reads each ATS at request time and keeps no customer records at rest, so the timeline you build lives in your database.
Every ATS models hiring pipelines differently. Some record 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 returns observable application state, explicit lifecycle timestamps, and linked objects (interviews, activities, scorecards, documents) through normalized objects fetched live from the source.
What counts as the candidate journey?
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.
Why does the journey live 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.
Which ATS objects make up the journey?
Coverage varies by object (as of 19 October 2026): applications come back on 125 ATS integrations, activities on 70, documents on 69, interviews on 44 and scorecards on 34. Interviews, for example, come back from Greenhouse, Lever, Ashby, SmartRecruiters, iCIMS, Workable and Jobvite, but BambooHR has no interview object. Check each object on the integration's Feature Support tab before you add it to your timeline.
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 (resumes, cover letters, offer letters) are what a candidate-facing portal or offer flow shows.
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.
Lifecycle timestamps are sparse: hired_at comes back on 40 application integrations, rejected_at on 32 and offers on 13 (as of 19 October 2026), so treat a missing milestone as "not recorded", not "didn't happen". Confirm enum strings (interview status, document type) on 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 expire after 1 hour, so request them when the user opens the document.
Step 8: Keep journeys current
On supported integrations, ATS list endpoints accept updated_gte for incremental refresh: applications on 61 of 125 integrations, interviews on 16 of 44 and scorecards on 14 of 34 (as of 19 October 2026), including Greenhouse and Lever. Where it isn't offered, re-read the application's linked objects or use change events:
- 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.
To be notified of changes, subscribe to Unified.to webhooks on ATS objects. Change events exist on supported integrations and objects: application updates on 74 ATS integrations and interview updates on 21 (as of 20 October 2026), most of them virtual webhooks, which check at an interval you set, as often as every minute on paid plans. Filters (exact match on list parameters such as application_id or job_id) work on virtual webhooks and, for many integrations, native webhooks; the integration's Feature Support tab lists which apply.
What doesn't Unified.to infer?
Unified.to doesn't reconstruct stage history, guess transition timestamps, treat activities as state changes, or enforce a single pipeline model. It returns what the ATS records and nothing more.
What should your timeline show?
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 one schema for application state, lifecycle timestamps and the linked interviews, activities, scorecards and documents, on the integrations that return them. Which of those your product shows is a product decision.
Related: How to Build a Candidate Portal Showing Interview Stages · ATS API Integration · Application model