Custom ATS Integration · Part 3 of 3

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.

Tip
Everything in this part is optional. Each section is independent of the others, so pick the ones that describe your situation and ignore the rest.
CandidatesPulling candidates into First

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.

GET/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:

json
{
  "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:

FieldRequiredNotes
idYesA string that identifies the application in your ATS, stable for its lifetime. We store it and address the record by it from then on.
emailYesAn 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.
cvYesThe 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_atYesWhen 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.
stageStrongly recommendedWhere they sit in your pipeline, as an id and name. We only ingest candidates still waiting to be screened. See below.
sourceStrongly recommendedHow the application reached you, as a name and method. We use it to attribute a hire to the right place. See below.
full_nameNoOne string, as the candidate gave it, like Jo Bloggs. We do not hold first and last names separately.
preferred_nameNoWhat 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.
phoneNoA mobile number with its country code, like +44 7700 900000. Without the code we assume the UK and mis-read everything else.
linkedin_urlNoA LinkedIn profile URL containing linkedin.com/in/, like linkedin.com/in/jo-bloggs. We normalise it; anything unrecognisable is dropped.
locationNoWhere the candidate is: a structured object or a plain string, whichever you hold. See below.
answersNoWhat the candidate answered on your side, as question and answer pairs. See below.
Example Response
json
{
  "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:

FieldRequiredNotes
cityNoThe town or city as free text, like London.
countryNoAn ISO 3166-1 alpha-2 code, like GB. A recognisable name such as United Kingdom also works; anything else is dropped.
latitudeNoDecimal degrees, like 51.5072. Send it with longitude or not at all; without coordinates we geocode from the city and country ourselves.
longitudeNoDecimal 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:

json
"location": "London, United Kingdom"
CV

This is the reverse of part one's CV: here you host the file and we fetch it.

FieldRequiredNotes
urlYesA 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.
filenameStrongly recommendedThe 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.

FieldRequiredNotes
idYesA string that identifies the stage, stable across renames.
nameStrongly recommendedThe 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.

FieldRequiredNotes
nameYesWhere the application came from, as free text: a job board like Indeed, an agency name, a referral, or your careers site.
methodNoOne 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.

FieldRequiredNotes
questionYesThe question as the candidate saw it, as text.
answerYesThe answer as text, however your ATS captured it. Flatten multiple-choice or file answers to something readable.
GET/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:

json
{ "applications": [] }

These are the only properties we read here:

FieldRequiredNotes
idYesThe same id as the list above. It is the identifier we failed to store, which is the whole point of the call.
emailNoThe email address you matched on, echoed back so we can confirm the right person.
Example Response
json
{
  "applications": [
    {
      "id": "your_ref_8871",
      "email": "jo.bloggs@example.com",
      "created_at": "2026-08-04T09:31:22Z"
    }
  ]
}
JobsFuller job detail

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.

GET/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:

json
{ "job": {} }

These are the properties we read from it:

FieldRequiredNotes
description_html / description_mdOne of the twoThe reason this endpoint exists. If your list could carry the description you would not need it.
Everything elseNoThe 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
json
{
  "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

First.cx logo
Copyright © OpenDigital Limited 2026
First.cx logo
Copyright © OpenDigital Limited 2026

We use cookies to analyse site traffic and improve your experience. By clicking "Accept", you consent to our use of analytics cookies. See our Privacy Policy for details.