How to Source Candidates Externally and Add Them to an ATS with
·
To add externally sourced candidates to your customers' ATS with Unified.to's ATS API, read the job's requirements, match candidates in your own system, search the ATS by email to avoid duplicates, then create the candidate and application and attach the resume. This guide is for sourcing and talent-matching products. Recruiters source from internal talent databases, enrichment vendors and sourcing tools; the integration work starts after that: adding candidates to the ATS without duplicates and with enough context for recruiters to act.
The steps:
- Read job requirements from the ATS
- Match against an external talent source
- De-duplicate candidates
- Create candidate
- Create application
- Attach resume
- Set stage
- Add tags and source metadata
The mental model: your sourcing engine is external, the ATS is the system of record
Your matching pipeline lives outside the ATS. But the ATS remains the operational system recruiters use. Your integration must:
- Avoid creating duplicate candidates
- Sequence writes predictably
- Handle provider variability (documents, stages, custom fields)
- Be idempotent in your own application logic
Unified.to provides normalized objects and consistent endpoints. It does not enforce upserts or de-dupe for you.
For field-level write rules (required fields, writable fields, document attachment by ATS), see How to Create Candidates and Applications in ATS Systems. As of 19 October 2026, Greenhouse, Lever, Workable, Ashby, Bullhorn, iCIMS, SmartRecruiters and Teamtailor create both candidates and applications; Workday (REST) creates candidates but has no application object; Oracle HCM is in beta. Custom fields go in metadata on supported integrations.
Step 1: How do you read job requirements from the ATS?
List jobs and filter to open roles
listAtsJobs takes a status filter on integrations such as Greenhouse, Lever, Ashby and Teamtailor; elsewhere, list jobs and filter locally.
const jobs = await sdk.ats.listAtsJobs({
connectionId,
status: 'OPEN', // if supported by the integration's job model
limit: 100,
offset: 0,
sort: 'updated_at',
order: 'asc',
fields: '',
raw: '',
});
What to extract for matching
From AtsJob, the most useful matching signals are:
name,descriptionminimum_experience_years,minimum_degreeemployment_type,remotequestions[](screening criteria)metadata[](customer-specific requirements)
Few integrations return the minimum-experience and degree fields (Bullhorn does); treat them, like skills[], as extras when present.
Do not rely on skills[] as a primary requirement signal. It exists in the normalized schema but is not consistently populated across ATS integrations.
Step 2: How do you match candidates externally?
This is your product's logic. A practical baseline is a hybrid approach:
- Hard filters: degree, minimum years, location/remote, work authorization (if you track it)
- Retrieval: embeddings over job description vs resume / experience text
- Reranking: rules + model score
The key output from this step should be:
- The external candidate record
- A match score + explanation (for auditability)
- A normalized candidate payload to write into the ATS
For matching on ATS data, see How to Match Candidates to Jobs Using ATS Data.
Step 3: How do you handle duplicate candidates?
Unified.to does not provide idempotency keys or upsert semantics for ATS writes. You must avoid duplicates explicitly.
listAtsCandidates supports a query parameter documented as:
'Query string to search. eg. email address or name'
Use it for email-based matching. Not every integration accepts query (Teamtailor doesn't); where it's missing, list candidates and match emails locally.
const existing = await sdk.ats.listAtsCandidates({
connectionId,
query: 'candidate@example.com',
limit: 10,
offset: 0,
sort: 'updated_at',
order: 'asc',
fields: '',
raw: '',
});
If you find a match, reuse that candidate id. If not, create a new candidate.
Step 4: How do you create a candidate?
const candidate = await sdk.ats.createAtsCandidate({
connectionId,
atsCandidate: {
first_name: 'Jane',
last_name: 'Doe',
emails: [{ email: 'jane@example.com', type: 'WORK' }],
title: 'Senior Backend Engineer',
company_name: 'Previous Co',
origin: 'SOURCED',
tags: ['SOURCED', 'EXTERNAL_MATCH'],
metadata: [
{ slug: 'source_system', value: 'external_db' },
{ slug: 'match_score', value: 0.87 },
],
},
fields: '',
raw: '',
});
Notes:
- Required fields vary by ATS integration.
- Some providers may require email/name.
- Treat tags and metadata as provider-dependent fields (they are part of the model, but field-level write support varies).
Step 5: How do you create an application?
const application = await sdk.ats.createAtsApplication({
connectionId,
atsApplication: {
candidate_id: candidate.id,
job_id: job.id,
status: 'NEW',
source: 'External sourcing pipeline',
},
fields: '',
raw: '',
});
Notes:
candidate_idandjob_idare typically required (integration-specific).statusis writable on create for many integrations.offers[]is writable on only a few integrations, such as Workday (Legacy).
Step 6: How do you attach the resume?
You can attach resumes using the Unified.to Document object. The key variability is:
- Some integrations accept
document_data(base64) - Some accept
document_url - Attachments may be candidate-bound or application-bound depending on the ATS integration
- Greenhouse, Workable and SmartRecruiters take
document_urlonly.
Variant A: Base64 resume upload
await sdk.ats.createAtsDocument({
connectionId,
atsDocument: {
filename: 'resume.pdf',
type: 'RESUME',
candidate_id: candidate.id,
document_data: base64EncodedResume,
},
fields: '',
raw: '',
});
Variant B: URL-based resume attachment
await sdk.ats.createAtsDocument({
connectionId,
atsDocument: {
filename: 'resume.pdf',
type: 'RESUME',
application_id: application.id,
document_url: 'https://your-storage.com/resumes/jane-doe.pdf',
},
fields: '',
raw: '',
});
For write flows, if you are providing document_url, ensure it is accessible to the ATS integration.
Step 7: How do you set the stage?
You can update the application status via:
await sdk.ats.updateAtsApplication({
connectionId,
id: application.id,
atsApplication: {
status: 'REVIEWING',
},
fields: '',
raw: '',
});
Notes:
- Stage transition rules are not documented as a Unified.to-wide model.
- On some integrations, such as Greenhouse,
statusis a slow field: request it withfieldswhen you read it back. - Handle 429/5XX with backoff and retry.
Step 8: How do you add tags and source metadata?
If you didn't apply tags at create time or want to add more context:
await sdk.ats.updateAtsCandidate({
connectionId,
id: candidate.id,
atsCandidate: {
tags: ['SOURCED', 'AI_MATCHED', 'HIGH_CONFIDENCE'],
metadata: [
{ slug: 'source_system', value: 'external_db' },
{ slug: 'source_run_id', value: 'run_2026_02_25_001' },
],
},
fields: '',
raw: '',
});
Notes:
- Tags and metadata are part of the normalized model.
- Actual write support may vary by provider; consult integration field support before relying on specific custom fields.
Recommended write sequencing
- Read job
- Run external matching
- Search ATS candidates by email (
query) - Create candidate if needed
- Create application
- Attach resume
- Update stage
- Update tags/metadata
Persist intermediate IDs after each step:
candidate.idapplication.id
Handling retries and safety
Unified.to does not provide atomic multi-step transactions across these calls. Your application should:
- Retry idempotently on 429/5XX
- Avoid duplicate candidate creation by always searching first
- Log every provider response
- Store 'write state' checkpoints so you can resume after a failure
What does Unified.to handle, and what do you build?
External sourcing only matters if recruiters can act on it inside their ATS.
Unified.to's ATS API gives you:
- A normalized job model to pull requirements
- Candidate search via
queryto dedupe by email/name - Candidate and application write endpoints
- Document attachment endpoints
- Application status updates
- Tags and metadata support
From there, reliable sourcing comes down to sequencing, deduplication, retries and fallbacks for each integration's limits.