Custom ATS Integration · Part 1 of 3

Connect your custom ATS

This guide covers the two endpoints that are the bare minimum to connect your ATS to First

This guide is for two sorts of reader: a customer whose ATS was built in house, along with the engineers who will do the work, and an ATS provider integrating their own product with First. First cannot connect to an ATS until it exposes API endpoints for us to call. This guide specifies the endpoints you need to build.

Tip
Are you using an off-the-shelf ATS? We already integrate with Greenhouse, Workable, Ashby, Teamtailor, Bullhorn, BambooHR, Pinpoint, Loxo and Eploy, and we can integrate with any ATS that has a published open API. You do not need this guide. See connect your ATS.
OverviewHow it works and what you build

The whole loop, in order:

  1. You expose GET /jobs. We poll it every 15 minutes and mirror your open jobs as roles in First.
  2. A candidate applies to one of those roles and answers the role's questions.
  3. When they submit, we POST the application to the matching job in your ATS, with their CV, their answers and a link back to First.
  4. First screens them against the role's criteria. Those results reach your ATS through part two.

That is two endpoints, JSON over HTTPS, and one credential. There are no webhooks or callbacks, nothing to install and nothing to run on a schedule: First makes all the calls and you are the server. If you already have a REST API, most of the work is mapping your field names onto ours.

ConventionsWhat we expect of the API

Transport

  • HTTPS only, TLS 1.2 or better, with a certificate from a public CA. We cannot integrate with an API on a private network unless you can expose a public endpoint. An allowlist of our egress IPs is fine, just ask us for them.
  • JSON request and response bodies. Multipart is acceptable for file upload only.
  • A single stable base URL per environment, e.g. https://ats.example.com/api/v1. If it varies per customer (https://{tenant}.example.com/api/v1), tell us the template. We already support per-tenant subdomains.
  • Version your API in the path. We pin to a version, and you can then evolve underneath it.

Authentication

In order of preference:

  1. Bearer token: Authorization: Bearer <token>. Long-lived, revocable, scoped to an account rather than a person. This is what most of our integrations use and it is the least work for both sides.
  2. API key header: X-API-Key: <key>. Fine.
  3. Basic auth. Fine.

We store all credentials encrypted at rest and never log them.

Identifiers, timestamps and text

  • Every job and candidate needs a stable, immutable, unique identifier. We persist it as the link between a First role or application and your record; if it changes, the link breaks.
  • Strings preferred. Integers are fine, but be consistent. Do not send "id": 42 from one endpoint and "id": "42" from another.
  • ISO 8601 with a timezone offset: 2026-08-04T09:30:00Z. Not epoch seconds, not local time without an offset.
  • Job descriptions may be HTML or Markdown. Say which. We convert HTML to Markdown on import, so semantic HTML is fine and <div> soup is not.
  • UTF-8 throughout. Candidate names contain accents and non-Latin scripts.

Pagination and filtering

Any list endpoint must paginate. Either style works:

json
// Cursor / next-link style (preferred)
{
  "jobs": [ /* … */ ],
  "paging": { "next": "https://ats.example.com/api/v1/jobs?cursor=eyJ" }
}

// Page-number style
{
  "jobs": [ /* … */ ],
  "paging": { "page": 1, "per_page": 50, "total_pages": 7, "total": 312 }
}

Omit paging.next (or return an empty page) when there are no more results. Do not loop back to page one. Keep ordering stable across pages. A default page size of 50 to 100 is ideal.

One filter does most of the work and saves both of us a great deal of traffic: updated_after on jobs, ISO 8601. Without it we must pull your entire job list every 15 minutes and diff it ourselves. That works, but it is wasteful.

Errors

Use real HTTP status codes, and keep them consistent:

StatusWe read it asWhat we do
200/201SuccessContinue
400Malformed requestLog and alert us. A bug on our side
401/403Bad or expired credentialsStop, and ask you to reconnect
404Record does not existTreat as gone. We may archive the role or stop retrying
409ConflictStop trying for that application, and do not retry
422Valid request, business rule refused itLog and skip, and do not retry
429Rate limitedBack off and retry, honouring Retry-After
5xxYour problemRetry with exponential backoff

The 404 / 422 distinction matters more than it looks. 422 is how we tell a business-rule refusal apart from a transient failure. If you return 500 for business-rule refusals, we will retry forever and generate noise for both of us.

Include a machine-readable body:

json
{
  "error": "candidate_already_exists",
  "message": "A candidate with that email already exists on job 9f3a."
}

Rate limits

Tell us the limits and return them in headers. We already honour Retry-After and a reset-timestamp header, and back off exponentially otherwise. We cap our own volume too. Job creation per sync run and candidate writes per batch are both bounded, so a first sync against a large ATS spreads over several runs rather than arriving as a spike.

ReferenceEndpoint specifications

The paths and field names below are a reference shape, not a mandate. If your API already exists and looks different, that is usually fine. We write a per-provider adapter anyway and can map field names, wrapper objects and ID types. What we cannot map around is missing capability: an endpoint that does not exist, an ID that is not stable, a list that cannot be filtered or paged.

If you are designing from scratch and have no opinion, copying this shape verbatim is the cheapest and fastest option for both sides.

GET/jobs?updated_after=

List jobs

This one call has to carry everything we need to build a role, including the description.

Two things tell us a job is no longer live. Its status, which closes or archives the role as set out below. Or its absence: a job that stops appearing in the list is archived too. There is nothing to return for a job that no longer exists, because a list endpoint simply leaves it out.

When we call it: every 15 minutes per connected organisation. On the very first sync we ask for the last 30 days.

The response is a jobs array, plus paging:

json
{
  "jobs": [],
  "paging": { "next": "https://ats.example.com/api/v1/jobs?cursor=eyJ" }
}

These are the properties of each job in that array:

FieldRequiredNotes
idYesA string that identifies the job, stable and immutable. We store it against the First role forever.
titleYesThe public-facing job title, as text, like Senior Backend Engineer. Becomes the role name.
statusYesA string from your own vocabulary, like open. See the status and visibility types below for how we bucket them.
updated_atYesWhen the job last changed, ISO 8601 with timezone, like 2026-08-04T08:41:00Z. Drives incremental sync, and must change whenever anything on the job changes.
description_html / description_mdOne of the twoThe advert body. Send whichever you hold, or both if you hold both. We convert HTML to Markdown on import, so sending description_md saves a conversion. A role without a description cannot be published publicly by First.
locationsAt least oneA role needs at least one location to be published, so a job without one stays private in First. See the locations array below.
visibilityNoA string from your own vocabulary, like public: whether the role is advertised. We assume public without one. See the status and visibility types below.
workplace_typeNoMaps to remote, hybrid, on-site, remote-first or flexible. A job-level field, not per location.
employment_typeNoMaps to full-time, part-time, contractor, internship or volunteer. Send your own values plus the list of possibilities and we will map them.
categoriesNoAn array of strings, which become the role's categories in First. Send the names you use, whether they are departments, functions or teams.
compensationNoThe salary and the flags around it. See the compensation object below.
teamNoAn array of the people on the job. See the team array below.
Example Response
json
{
    "jobs": [
      {
        "id": "job_9f3a",
        "title": "Senior Backend Engineer",
        "internal_title": "Senior Backend Engineer, Platform (London)",
        "status": "open",
        "visibility": "public",
        "url": "https://careers.example.com/jobs/senior-backend-engineer",
        "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": {
              "line_one": "1 Example Street",
              "line_two": "Floor 3",
              "city": "London",
              "region": "England",
              "postcode": "EC1A 1AA",
              "country": "GB"
            },
            "coordinates": { "latitude": 51.5072, "longitude": -0.1276 }
          },
          { "title": "Remote, United Kingdom" }
        ],
        "workplace_type": "hybrid",
        "employment_type": "full_time",
        "compensation": {
          "salary": {
            "type": "salary-range",
            "currency": "GBP",
            "min_value": 75000,
            "max_value": 95000,
            "frequency": "yearly"
          },
          "incentive_compensation": "10% annual bonus and share options",
          "estimate_only": false,
          "display_to_applicants": true
        },
        "team": [
          { "full_name": "Alex Doe", "email": "alex@example.com", "role": "admin", "is_hiring_manager": true },
          { "full_name": "Sam Ray", "email": "sam@example.com", "role": "member" }
        ],
        "created_at": "2026-07-02T11:04:00Z",
        "updated_at": "2026-08-04T08:41:00Z"
      }
    ],
    "paging": { "next": "https://ats.example.com/api/v1/jobs?cursor=eyJ" }
  }
Status and visibility types

We need to know two things about each job: whether it is live and taking applications, and whether it should be advertised publicly. Send whatever your ATS calls them and give us the full list of possible values with their meanings, and we map them.

Status. Our other integrations map onto four buckets:

BucketMeaningWhat First does
draftNot yet liveImport as a draft role, not published
openLive and accepting applicationsImport and publish
closedFilled or stopped, historically visibleClose the role, stop accepting applications
archivedGoneArchive the role

Visibility. Independent of status, because a live role is not always one you want listed:

ValueWhat First does
publicAdvertise the role. It appears on the job board and candidates can apply. This is what we assume if you send nothing.
privateKeep the role in First but do not advertise it. Use this for confidential vacancies, and for roles you only ever want candidates pushed into.

A role missing a description or a location stays private whatever you send here, because First cannot publish one without both.

Locations

An array, because a job can be open in more than one place. Each entry needs a title and nothing else, though the address and coordinates are worth sending if you hold them.

FieldRequiredNotes
titleYesWhat you call the place, for example London office.
address.line_oneNoStreet address.
address.line_twoNoA second address line, if you hold one.
address.cityNoTown or city.
address.regionNoCounty, state or province.
address.postcodeNoPostcode or ZIP.
address.countryNoAn ISO 3166-1 alpha-2 code if you can, for example GB. We normalise names to codes on import.
coordinatesNolatitude and longitude, both or neither. With them we can screen and source candidates by distance from the location. Without them we geocode the address, which is less exact.
Compensation

compensation.salary is one of two shapes, picked by type. The three flags around it decide how we present the figure. Leave compensation out altogether if there is no salary to publish.

FieldRequiredNotes
salary.typeYessalary for a single figure, or salary-range.
salary.valueNoThe figure, when type is salary.
salary.min_value / salary.max_valueNoThe bounds, when type is salary-range.
salary.currencyNoISO 4217, for example GBP. We assume GBP without one.
salary.frequencyNoyearly, monthly, weekly, daily or hourly. We assume yearly without one.
incentive_compensationNoFree text for bonus, commission or equity. Shown alongside the salary.
estimate_onlyNotrue when the figure is your estimate rather than the advertised band, so we can label it as one.
display_to_applicantsNofalse keeps the figure internal to recruiters. We show it to candidates by default.
json
// A single figure. Any frequency, so this one is a contractor day rate
{
  "salary": {
    "type": "salary",
    "currency": "GBP",
    "value": 450,
    "frequency": "daily"
  }
}

// A range, with the optional extras
{
  "salary": {
    "type": "salary-range",
    "currency": "GBP",
    "min_value": 75000,
    "max_value": 95000,
    "frequency": "yearly"
  },
  "incentive_compensation": "10% annual bonus and share options",
  "estimate_only": false,
  "display_to_applicants": true
}
Team

The people on the job. We use this to set the hiring manager on the imported role, and to attribute work to the right person.

FieldRequiredNotes
emailYesAn email address, like alex@example.com: how we match your people to First users. If the email does not match a user in First we ignore that team member, and the same goes for an entry sent without one.
full_nameNoThe person's name as one string.
roleNoTheir access to the role in First, either admin or member. A admin can manage the role and change its criteria and settings. A member works on it without managing it. Send us your own labels and the full list of possible values, and we map them onto these two. We assume member without one.
is_hiring_managerNotrue on the person to assign as the role's hiring manager in First. Without it we assume the first admin listed is the hiring manager.
POST/jobs/{jobId}/applications

Create an application on a job

An application is one person applying to one job. The same person applying to two jobs is two applications, and both should exist.

We call this once per application. We store the identifier you return and address the record by it from then on, so later changes go to the endpoints in candidate updates rather than back here.

When we call it: after the candidate submits their application.

We send a single application object:

json
{ "application": {} }

These are the properties we send:

FieldRequiredNotes
idYesOur identifier for the application, as a string. Store it against your record: it is how we recognise the application if we ever have to look it up again.
urlYesA link to the application in First, where a recruiter sees the full assessment. If you have a custom field that renders as a clickable URL, tell us and we will populate it.
emailYesAn email address, like name@example.com. Our primary identity key.
full_nameYesOne string, as the candidate gave it, like Jo Bloggs. We do not hold first and last names separately, so split it yourself if your ATS needs them apart.
preferred_nameYesWhat they asked to be called, like Jo. Falls back to their full name where they did not give one.
phoneYesA mobile number with its country code, like +44 7700 900000.
sourceYesHow the application reached First, as a type, method and name. See below.
linkedin_urlNoA LinkedIn profile URL, normalised to https://www.linkedin.com/in/….
locationNoWhere the candidate is: city, country and coordinates where we hold them. See below.
cvNoThe CV they uploaded, as a url and filename. See below.
submitted_atNoWhen they submitted, ISO 8601 with timezone, like 2026-08-04T09:31:22Z.
answersNoWhat they answered to the role's questions, as question and answer pairs. See below.

Respond 201 Created with your own identifier for the application.

Tip
We need your own identifier back in the response. It is the handle for everything in candidate updates: notes, stage moves, disqualification, CV attachment. A create endpoint that returns 204 No Content leaves us with no way to address the application again.
Example Request
json
{
  "application": {
    "id": "app_88",
    "url": "https://admin.example.com/share-application/org_1/app_88",
    "submitted_at": "2026-08-04T09:31:22Z",
    "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",
      "latitude": 51.5072,
      "longitude": -0.1276
    },
    "cv": {
      "url": "https://files.first.example/…?signature=…",
      "filename": "Jo_Bloggs_CV.pdf"
    },
    "answers": [
      { "question": "Do you have the right to work in the UK?", "answer": "Yes" },
      { "question": "Notice period", "answer": "1 month" }
    ],
    "source": { "type": "direct", "method": "applied", "name": "First" }
  }
}
Example Response
json
{
  "application": {
    "id": "your_ref_8871",
    "job_id": "job_9f3a",
    "email": "jo.bloggs@example.com",
    "created_at": "2026-08-04T09:31:22Z"
  }
}
Source

How the application reached First. It matters for your reporting: an application we forwarded from a job board is not the same as one a recruiter went out and found.

FieldRequiredNotes
typeYesdirect when they applied through First, or ats when the application started in another system and First picked it up.
methodYesapplied when they came to us, or sourced when a recruiter found them. Several ATSs report on the two separately.
nameYesWhere it came from, as free text, for us to attribute the hire. First for a direct application, otherwise the system it arrived from.
Location

Where the candidate is, as far as we know it. Geocoding is best-effort, so an application may carry a city with no coordinates, or nothing at all.

FieldRequiredNotes
cityNoThe town or city the candidate gave, as free text, like London.
countryNoAn ISO 3166-1 alpha-2 code, for example GB.
latitudeNoDecimal degrees, like 51.5072, where we managed to geocode them. Sent with longitude or not at all.
longitudeNoDecimal degrees, like -0.1276, alongside latitude.
Answers

An array, one entry per question the candidate answered. The questions are the role's own, so they differ between roles and change when a recruiter edits them.

FieldRequiredNotes
questionYesThe question as the candidate saw it, as text.
answerYesThe answer, as text.
CV

Candidates upload a CV to First and we need to get it into your ATS. For the minimal integration there is one mechanism to support, and it is the least work for both sides: we put a link on the payload and you fetch the file.

FieldRequiredNotes
urlYesA time-limited signed URL to the file itself. No credentials needed, so a plain GET is enough.
filenameYesThe name as the candidate uploaded it, for example Jo_Bloggs_CV.pdf. Use it rather than deriving a name from the URL, which carries a signature.

Fetch it promptly and store your own copy. The URL expires, so a link saved against your record will stop working.

We send the file the candidate uploaded, unconverted, so it can be PDF, DOCX, RTF, TXT, Markdown or HTML. Accept PDF and DOCX at minimum, and tell us if any of the others would be rejected.

If fetching a URL is awkward for you, we can push the bytes instead, by multipart upload or base64 in JSON. See part two for details, as this requires an additional endpoint.

HandoverWhat to send us

Send us this and we can scope the build accurately.

  • API reference for every endpoint you are providing, with request and response schemas
  • A real captured JSON response for each one, rather than a hand-written example. We validate against your responses, and prose always misses a null or an ID that is sometimes numeric
  • Your complete enum values: job statuses, visibility, employment and workplace types, and team roles
  • Pagination style and page size limits
  • Auth method and token lifetime
  • Somewhere to build against: a sandbox with credentials, seeded with jobs that have descriptions, or a test role in your live environment
  • Production base URL, how you issue credentials, and a named technical contact
QuestionsFrequently asked
Do we have to match your paths and field names exactly?

No. We write a per-provider adapter for every ATS we integrate with, and mapping field names, wrapper objects and ID types is routine. Matching the shape here just makes the build faster. What we cannot work around is missing capability or unstable identifiers.

Who builds the integration, you or us?

You build and host the API. We write and maintain the adapter that calls it, the same way we do for Greenhouse or Ashby. You will not be asked to learn anything about how First works internally.

Can we build these two endpoints now and more later?

Yes, and most customers should. These two deliver the core value: your jobs in First, and screened candidates flowing back into your ATS. Candidate updates and ingestion can follow once that is live.

Can we control which jobs First sees?

Send everything the credential can see, whatever state it is in. Recruiters choose which jobs to import once they are in First, so filtering on your side only hides jobs they might have wanted.

What happens if our API goes down?

An outage delays a sync rather than losing it. We retry 5xx with exponential backoff, and honour Retry-After on 429. Jobs are re-read on the next poll, and candidate writes are retried until they succeed.

How do we test this before it goes live?

A sandbox or test instance is best. Seed it with jobs that have descriptions so we can exercise the whole flow.

If you do not have one, a test role in your live environment is fine, as long as you clean up the test applications afterwards. What we cannot do is build against your real vacancies, because neither of us wants a test candidate in front of a hiring manager.

Can we use AI to build our API?

Yes. Point your coding agent at this page and it has what it needs: the endpoints, the field definitions and the example payloads.

Send us the captured responses from what it builds, as above. Generated code drifts from generated documentation, and the real responses are what we validate against.

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.