Candidate ingestion and optional extras
Pull in candidates who reached your ATS by other routes.
Parts one and two cover the round trip: your jobs come in, screened candidates go back. This part is optional. It reads applications that reached your ATS some other way, so First can screen those too.
Candidates often reach an ATS by routes that have nothing to do with First: job boards, agencies, referrals, direct applications. If you want those people assessed by First too, we need to be able to read them.
The flow is: page through your candidates for a connected job, skip anyone we already hold, and assess the rest. Assessment invitations then go out from First, and their results come back to you through candidate updates like any other candidate.
/jobs/{jobId}/applications?created_after=List applications on a job
We page through, skip anyone we already hold, and fetch the detail for the rest.
When we call it: on a schedule per connected role, and on demand when a recruiter asks for a sync.
The response is an applications array, plus paging:
{ "applications": [], "paging": { "next": "https://ats.example.com/api/v1/jobs/job_9f3a/applications?cursor=eyJ" } }
One filter makes this much cheaper: created_after, so we only read what is new rather than the whole pipeline every run.
We will not re-import candidates First created itself. We match each application against the identifiers we already hold, the id your create endpoint returned and the candidate's email, and skip anything we recognise.
These are the properties of each application in that array:
| Field | Required | Notes |
|---|---|---|
id | Yes | A string that identifies the application in your ATS, stable for its lifetime. We store it and address the record by it from then on. |
email | Yes | An email address in the usual format, like name@example.com. Our identity key: it is how we tell applications apart and avoid importing one twice. |
cv | Yes | The file they gave you, as a url and filename. First screens from the CV, so we skip applications that do not carry one. See below. |
created_at | Yes | When the application reached you, ISO 8601 with timezone, like 2026-08-04T09:31:22Z. Drives incremental pulls, so we are not re-reading the whole pipeline every run. |
stage | Strongly recommended | Where they sit in your pipeline, as an id and name. We only ingest candidates still waiting to be screened. See below. |
source | Strongly recommended | How the application reached you, as a name and method. We use it to attribute a hire to the right place. See below. |
full_name | No | One string, as the candidate gave it, like Jo Bloggs. We do not hold first and last names separately. |
preferred_name | No | What they asked to be called, like Jo. First uses it to address the candidate, so only send it where you asked them directly; leave it out rather than deriving it from their name. |
phone | No | A mobile number with its country code, like +44 7700 900000. Without the code we assume the UK and mis-read everything else. |
linkedin_url | No | A LinkedIn profile URL containing linkedin.com/in/, like linkedin.com/in/jo-bloggs. We normalise it; anything unrecognisable is dropped. |
location | No | Where the candidate is: a structured object or a plain string, whichever you hold. See below. |
answers | No | What the candidate answered on your side, as question and answer pairs. See below. |
Example Response▼
{ "applications": [ { "id": "your_ref_8871", "email": "jo.bloggs@example.com", "full_name": "Jo Bloggs", "preferred_name": "Jo", "phone": "+44 7700 900000", "linkedin_url": "https://www.linkedin.com/in/jobloggs", "location": { "city": "London", "country": "GB" }, "cv": { "url": "https://ats.example.com/files/…", "filename": "Jo_Bloggs_CV.pdf" }, "stage": { "id": "stage_1", "name": "Applied" }, "source": { "name": "Indeed", "method": "applied" }, "answers": [ { "question": "Do you have the right to work in the UK?", "answer": "Yes" } ], "created_at": "2026-08-04T09:31:22Z" } ], "paging": { "next": "https://ats.example.com/api/v1/jobs/job_9f3a/applications?cursor=eyJ" } }
Location▼
Where the candidate is, in whichever shape your ATS holds it. Send the structured object if you have one:
| Field | Required | Notes |
|---|---|---|
city | No | The town or city as free text, like London. |
country | No | An ISO 3166-1 alpha-2 code, like GB. A recognisable name such as United Kingdom also works; anything else is dropped. |
latitude | No | Decimal degrees, like 51.5072. Send it with longitude or not at all; without coordinates we geocode from the city and country ourselves. |
longitude | No | Decimal degrees, like -0.1276, alongside latitude. |
If you hold location as one free-text field, send it as a plain string instead and we will parse it on our side:
"location": "London, United Kingdom"CV▼
This is the reverse of part one's CV: here you host the file and we fetch it.
| Field | Required | Notes |
|---|---|---|
url | Yes | A URL serving the file bytes: either one we can GET with the same credentials as the rest of your API, or a signed URL needing none. Tell us which, and how long signed URLs live. |
filename | Strongly recommended | The name as the candidate uploaded it, with its extension, like Jo_Bloggs_CV.pdf. It is how the file keeps its identity in First. |
Send the file as you hold it. PDF and DOCX are the common cases, and we also accept RTF, TXT, Markdown and HTML.
Stage▼
Where the candidate sits in your pipeline. We only ingest from the stages agreed at setup, typically your Applied and Sourced stages, so someone already interviewing, hired or disqualified is not pulled in and screened again.
| Field | Required | Notes |
|---|---|---|
id | Yes | A string that identifies the stage, stable across renames. |
name | Strongly recommended | The label your recruiters see, like Applied. |
If a disqualified candidate keeps their stage in your ATS, leave them out of this list rather than relying on us to spot them.
Source▼
How the application reached you, so a hire is attributed to the right place in reporting.
| Field | Required | Notes |
|---|---|---|
name | Yes | Where the application came from, as free text: a job board like Indeed, an agency name, a referral, or your careers site. |
method | No | One of applied, when the candidate came to you, or sourced, when a recruiter found them. Omit it if you do not distinguish the two. |
Answers▼
Anything the candidate answered on your side, one entry per question. We store them against the application so recruiters see them in First.
| Field | Required | Notes |
|---|---|---|
question | Yes | The question as the candidate saw it, as text. |
answer | Yes | The answer as text, however your ATS captured it. Flatten multiple-choice or file answers to something readable. |
/jobs/{jobId}/applications?email=Find an application by email
A recovery mechanism rather than a routine call. If a create times out after your side committed it, this is how we find the record we cannot otherwise address, instead of creating a second one.
It is only worth building if your ATS refuses duplicate creates without returning the existing record. If you answer with 200 and the record you already hold, you do not need this.
Return an empty array, not 404, when there is no match. Email matching should be case-insensitive.
The response is the same applications array as the list above, filtered to the email you were given:
{ "applications": [] }These are the only properties we read here:
| Field | Required | Notes |
|---|---|---|
id | Yes | The same id as the list above. It is the identifier we failed to store, which is the whole point of the call. |
email | No | The email address you matched on, echoed back so we can confirm the right person. |
Example Response▼
{ "applications": [ { "id": "your_ref_8871", "email": "jo.bloggs@example.com", "created_at": "2026-08-04T09:31:22Z" } ] }
Part one asks your job list to carry everything needed to build a role, description included. Many ATSs return a thinner object from a list than from a detail endpoint, and if yours is one of them, this endpoint closes the gap.
/jobs/{jobId}Get a job
When we call it: once per job that appeared in the list with a changed updated_at, and on demand when a recruiter asks to sync a role.
The response is a single job:
{ "job": {} }These are the properties we read from it:
| Field | Required | Notes |
|---|---|---|
description_html / description_md | One of the two | The reason this endpoint exists. If your list could carry the description you would not need it. |
| Everything else | No | The same properties as the job list, with the same meanings. Tell us which are detail-only, so we do not build schemas that break on real list data. |
Deleted jobs: return 404 for a job that no longer exists, which is how we archive the role. Do not return 200 with an empty body.
Example Response▼
{ "job": { "id": "job_9f3a", "title": "Senior Backend Engineer", "status": "open", "visibility": "public", "description_html": "<h2>About the role</h2><p>You will…</p>", "description_md": "## About the role\n\nYou will…", "categories": ["Technology", "Engineering", "Platform"], "locations": [ { "title": "London office", "address": { "city": "London", "country": "GB" }, "coordinates": { "latitude": 51.5072, "longitude": -0.1276 } } ], "workplace_type": "hybrid", "employment_type": "full_time", "updated_at": "2026-08-04T08:41:00Z" } }
Something here impossible or expensive in your ATS?
Tell us early. There is usually an alternative, and we would rather adjust the specification than have you build the wrong thing. Get in touch with your First contact, or at support@first.cx