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.
The whole loop, in order:
- You expose
GET /jobs. We poll it every 15 minutes and mirror your open jobs as roles in First. - A candidate applies to one of those roles and answers the role's questions.
- When they submit, we
POSTthe application to the matching job in your ATS, with their CV, their answers and a link back to First. - 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.
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:
- 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. - API key header:
X-API-Key: <key>. Fine. - 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": 42from 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:
// 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:
| Status | We read it as | What we do |
|---|---|---|
200/201 | Success | Continue |
400 | Malformed request | Log and alert us. A bug on our side |
401/403 | Bad or expired credentials | Stop, and ask you to reconnect |
404 | Record does not exist | Treat as gone. We may archive the role or stop retrying |
409 | Conflict | Stop trying for that application, and do not retry |
422 | Valid request, business rule refused it | Log and skip, and do not retry |
429 | Rate limited | Back off and retry, honouring Retry-After |
5xx | Your problem | Retry 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:
{ "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.
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.
/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:
{ "jobs": [], "paging": { "next": "https://ats.example.com/api/v1/jobs?cursor=eyJ" } }
These are the properties of each job in that array:
| Field | Required | Notes |
|---|---|---|
id | Yes | A string that identifies the job, stable and immutable. We store it against the First role forever. |
title | Yes | The public-facing job title, as text, like Senior Backend Engineer. Becomes the role name. |
status | Yes | A string from your own vocabulary, like open. See the status and visibility types below for how we bucket them. |
updated_at | Yes | When 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_md | One of the two | The 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. |
locations | At least one | A role needs at least one location to be published, so a job without one stays private in First. See the locations array below. |
visibility | No | A 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_type | No | Maps to remote, hybrid, on-site, remote-first or flexible. A job-level field, not per location. |
employment_type | No | Maps to full-time, part-time, contractor, internship or volunteer. Send your own values plus the list of possibilities and we will map them. |
categories | No | An array of strings, which become the role's categories in First. Send the names you use, whether they are departments, functions or teams. |
compensation | No | The salary and the flags around it. See the compensation object below. |
team | No | An array of the people on the job. See the team array below. |
Example Response▼
{ "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:
| Bucket | Meaning | What First does |
|---|---|---|
draft | Not yet live | Import as a draft role, not published |
open | Live and accepting applications | Import and publish |
closed | Filled or stopped, historically visible | Close the role, stop accepting applications |
archived | Gone | Archive the role |
Visibility. Independent of status, because a live role is not always one you want listed:
| Value | What First does |
|---|---|
public | Advertise the role. It appears on the job board and candidates can apply. This is what we assume if you send nothing. |
private | Keep 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.
| Field | Required | Notes |
|---|---|---|
title | Yes | What you call the place, for example London office. |
address.line_one | No | Street address. |
address.line_two | No | A second address line, if you hold one. |
address.city | No | Town or city. |
address.region | No | County, state or province. |
address.postcode | No | Postcode or ZIP. |
address.country | No | An ISO 3166-1 alpha-2 code if you can, for example GB. We normalise names to codes on import. |
coordinates | No | latitude 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.
| Field | Required | Notes |
|---|---|---|
salary.type | Yes | salary for a single figure, or salary-range. |
salary.value | No | The figure, when type is salary. |
salary.min_value / salary.max_value | No | The bounds, when type is salary-range. |
salary.currency | No | ISO 4217, for example GBP. We assume GBP without one. |
salary.frequency | No | yearly, monthly, weekly, daily or hourly. We assume yearly without one. |
incentive_compensation | No | Free text for bonus, commission or equity. Shown alongside the salary. |
estimate_only | No | true when the figure is your estimate rather than the advertised band, so we can label it as one. |
display_to_applicants | No | false keeps the figure internal to recruiters. We show it to candidates by default. |
// 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.
| Field | Required | Notes |
|---|---|---|
email | Yes | An 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_name | No | The person's name as one string. |
role | No | Their 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_manager | No | true 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. |
/jobs/{jobId}/applicationsCreate 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:
{ "application": {} }These are the properties we send:
| Field | Required | Notes |
|---|---|---|
id | Yes | Our 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. |
url | Yes | A 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. |
email | Yes | An email address, like name@example.com. Our primary identity key. |
full_name | Yes | One 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_name | Yes | What they asked to be called, like Jo. Falls back to their full name where they did not give one. |
phone | Yes | A mobile number with its country code, like +44 7700 900000. |
source | Yes | How the application reached First, as a type, method and name. See below. |
linkedin_url | No | A LinkedIn profile URL, normalised to https://www.linkedin.com/in/…. |
location | No | Where the candidate is: city, country and coordinates where we hold them. See below. |
cv | No | The CV they uploaded, as a url and filename. See below. |
submitted_at | No | When they submitted, ISO 8601 with timezone, like 2026-08-04T09:31:22Z. |
answers | No | What they answered to the role's questions, as question and answer pairs. See below. |
Respond 201 Created with your own identifier for the application.
204 No Content leaves us with no way to address the application again.Example Request▼
{ "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▼
{ "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.
| Field | Required | Notes |
|---|---|---|
type | Yes | direct when they applied through First, or ats when the application started in another system and First picked it up. |
method | Yes | applied when they came to us, or sourced when a recruiter found them. Several ATSs report on the two separately. |
name | Yes | Where 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.
| Field | Required | Notes |
|---|---|---|
city | No | The town or city the candidate gave, as free text, like London. |
country | No | An ISO 3166-1 alpha-2 code, for example GB. |
latitude | No | Decimal degrees, like 51.5072, where we managed to geocode them. Sent with longitude or not at all. |
longitude | No | Decimal 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.
| Field | Required | Notes |
|---|---|---|
question | Yes | The question as the candidate saw it, as text. |
answer | Yes | The 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.
| Field | Required | Notes |
|---|---|---|
url | Yes | A time-limited signed URL to the file itself. No credentials needed, so a plain GET is enough. |
filename | Yes | The 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.
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
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