How to build an ATS: data model, pipeline, and what to buy
Published
How to build an ATS: the data model, the pipeline, and the parts to buy
To build an ATS, you build four things: a data model for jobs, candidates and applications; a pipeline that moves each application through explicit stages; an intake step that turns every uploaded CV into structured data; and a search over that data. Everything else an applicant tracking system does (email, scheduling, reports, careers pages) hangs off those four. Get them right and the rest is ordinary product work. Get the data model wrong and every feature after it fights you.
This guide is for the developer building an ATS into a job board, a staffing tool or an internal hiring app. It walks the whole job in order, says where your own stack is enough, and names the parts that are usually cheaper to buy. We build ResumeJSON, a CV parsing API, so read the intake section as written by an interested party. The rest applies whatever you parse with.
Decide what your ATS is for before you write a table
"ATS" covers very different products. A careers page for one company, a recruiting agency juggling hundreds of clients and a job board that forwards applications all track applicants, and they need different things. Answer these first:
| Question | Why it changes the build |
|---|---|
| One employer or many? | Many means every row carries a tenant id, and every query filters on it |
| Who uses it? | Recruiters need queues and bulk actions; hiring managers need a short, read-mostly view |
| Where do applicants come from? | Your own form, job board postings, email, or bulk imports of old CVs |
| Does it reject anyone automatically? | If yes, you need reasons stored per decision, and a human review step |
| What volume? | Tens of applications a month and hundreds a day are different architectures |
Write the answers down. They are the requirements every later choice is checked against.
Step 1: the data model
Most ATS bugs trace back to one early mistake: treating a candidate and an application as the same thing. They are not. One person applies to three jobs over two years. Their details are one record; each application is another, with its own stage, notes and outcome.
The core tables:
| Table | Holds | Key relations |
|---|---|---|
jobs | Title, location, status (draft, open, closed), requirements | Belongs to an employer |
candidates | One person: name, email, phone, location | Unique on normalised email per tenant |
resumes | The original file, plus the parsed JSON | Belongs to a candidate; a candidate can have several |
applications | One candidate for one job: current stage, source, dates | Candidate and job |
stage_events | Every move between stages, who made it, why | Belongs to an application |
notes | Free text from recruiters and interviewers | Belongs to an application |
Three decisions in that table save a lot of pain later:
- Keep the original file forever, and the parsed JSON beside it. Parsers improve. Being able to re-parse the source later is worth the storage.
- Stage history is an append-only table, rather than a column you overwrite. "When did this person reach interview, and who moved them?" is a question every recruiter asks, and a single
stagecolumn cannot answer it. - Deduplicate candidates by normalised email. Lowercase it and trim it. Two applications from the same address are the same person applying twice, and your recruiters want to see that.
Step 2: the pipeline as a state machine
An application moves through stages: applied, screened, interview, offer, hired, and one or more closed states (rejected, withdrawn). Model this as a small, closed set of states with explicit allowed moves.
A minimal version in TypeScript:
type Stage = 'applied' | 'screening' | 'interview' | 'offer' | 'hired' | 'rejected' | 'withdrawn';
const allowed: Record<Stage, Stage[]> = {
applied: ['screening', 'rejected', 'withdrawn'],
screening: ['interview', 'rejected', 'withdrawn'],
interview: ['offer', 'rejected', 'withdrawn'],
offer: ['hired', 'rejected', 'withdrawn'],
hired: [],
rejected: [],
withdrawn: [],
};
function move(app: Application, to: Stage, by: UserId, reason: string): StageEvent {
if (!allowed[app.stage].includes(to)) {
throw new Error(`Cannot move from ${app.stage} to ${to}`);
}
return { applicationId: app.id, from: app.stage, to, by, reason, at: new Date() };
}Why this shape pays off:
- A closed type makes the compiler find every screen that forgot a stage. Add
assessmentlater and every exhaustiveswitchoverStagefails to build until it handles it. - Every move carries a reason. When you reject, the reason is stored with the event, which is what you need when a candidate or an auditor asks why.
- Employers will want custom stages. Let them rename and add stages inside the fixed categories (screening, interview, offer), so your reports still work across employers.
Step 3: intake, turning a CV into data
This is the step most home-built ATS projects underestimate. Candidates upload a PDF or a Word file. Your pipeline, search and screening all want fields: name, email, each job with dates, skills, education. Something has to get from one to the other.
You have three honest options:
- Make the candidate type it. A structured application form gives you clean data. It also loses applicants, because people already have a CV and would rather upload it.
- Parse it yourself. Extract the text, then pull the fields out with rules or a language model. We have written up that path in Python, Node.js and with ChatGPT. It works for the common layouts; two-column CVs, scanned pages and dates written five different ways are where the time goes.
- Call a parsing API. Send the file, get typed JSON back, store it in
resumes. You pay per document and skip maintaining the extraction.
Whichever you choose, map the result to your own schema at the boundary. Your candidates table should not change shape because you switched parsers. A thin adapter that turns parser output into your record type is the one place that knows about the vendor.
With ResumeJSON, intake is one request per file:
curl -X POST 'https://resumejson-resume-cv-parser-api.p.rapidapi.com/v1/parse' \
-H 'x-rapidapi-host: resumejson-resume-cv-parser-api.p.rapidapi.com' \
-H 'x-rapidapi-key: YOUR_KEY' \
-F 'file=@cv.pdf'It accepts PDF, DOCX and images of a page, answers on the same request (there are no webhooks or job ids to poll), and takes one CV per call. For an ATS that means intake can run inline when a candidate applies, or as a background job when you import a backlog. See bulk resume parsing for the backlog case.
Treat a failed parse as a state of the application. A corrupt or encrypted file will fail to parse. Store the application anyway, mark the resume as unparsed, and show the recruiter the original file. Losing an applicant because their PDF was odd is the worst outcome an ATS can have.
Step 4: search and filtering
Recruiters live in search. Once every CV is structured data, most of what they ask for is a plain query:
| Recruiter asks | Query over |
|---|---|
| "Everyone in Berlin who knows Go" | location, skills |
| "Applied in the last week, not yet screened" | applications.created_at, stage |
| "At least five years of experience" | work history dates, or a computed total |
| "Has worked at a company like ours" | employer names in work history |
Start with your database. PostgreSQL full-text search plus a JSONB column for the parsed CV covers a surprising amount, and a GIN index on the skills array keeps skill filters fast. Move to a dedicated search engine when you need typo tolerance, synonym handling or ranking across very large volumes, not before.
Normalise skills when you store them. "Postgres", "PostgreSQL" and "postgresql" should be one skill in search. A small synonyms table you own beats hoping the recruiter types the same spelling the candidate did.
Step 5: screening, if you do it
If your ATS sorts or screens applicants automatically, build it on the structured fields from Step 3, with rules you can explain. We covered this in depth in automated resume screening. The short version:
- Write each requirement as a rule over a field (holds a certification, a minimum number of years, a must-have skill).
- Store the verdict and its reasons on the application.
- Let automation sort the queue; let a person make every rejection. Some places regulate automated hiring decisions. Check the rules where you hire.
Step 6: the parts to buy instead of build
An ATS touches several problems that are whole products in their own right. Building each one yourself is rarely where your time is best spent:
| Part | Build it when | Buy it when |
|---|---|---|
| CV parsing | Your CVs come from one template or one country | CVs arrive in any layout, language or file type |
| Email sending | Never; use a sending service | Always |
| Interview scheduling | You only need a link to a calendar tool | You need panel scheduling across time zones |
| Job board distribution | You post to one or two boards by hand | You post to many boards and need applications back |
| Background checks | Never | Always, through a provider licensed where you hire |
Everything in the right-hand column still connects through an adapter you own, so swapping a provider is a change in one file.
When an off-the-shelf ATS is the better choice
Building your own applicant tracking system is not always the right call. Use an existing ATS when:
- You are hiring for your own company, not selling hiring software. A hosted ATS gives you pipelines, email templates and reports on day one.
- You need compliance features now. Data retention rules, consent records and equal opportunity reporting are real work, and established vendors have done it.
- Hiring is not your product. If the ATS is a means to an end, the months spent building it are months not spent on what you sell.
Build your own when applicant tracking is part of your product: a job board that wants applications to stay on its site, a staffing platform with its own workflow, or a vertical tool where the hiring flow is unusual enough that a generic ATS gets in the way.
Where ResumeJSON fits
ResumeJSON is the intake step from Step 3 and nothing else. It does not store candidates, move stages or screen anyone. It turns one CV into one JSON record, the same schema every time, with dates normalised and missing values as null rather than guessed.
As of September 2026 the API is sold through RapidAPI. The Basic plan is $0 for 100 parses a month with a hard cap, Pro is pay per use at $0.05 a parse, and paid plans start at $29 a month for 1,000 parses. The docs carry the current table and the full field reference, and the free parser lets you try one CV in the browser with no signup.
Start with the data model. Separate candidates from applications, keep stage history append-only, and store every original file beside its parsed JSON. The rest of the ATS builds cleanly on top of those three decisions.