ResumeJSON

How to build an ATS: data model, pipeline, and what to buy

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:

QuestionWhy 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:

TableHoldsKey relations
jobsTitle, location, status (draft, open, closed), requirementsBelongs to an employer
candidatesOne person: name, email, phone, locationUnique on normalised email per tenant
resumesThe original file, plus the parsed JSONBelongs to a candidate; a candidate can have several
applicationsOne candidate for one job: current stage, source, datesCandidate and job
stage_eventsEvery move between stages, who made it, whyBelongs to an application
notesFree text from recruiters and interviewersBelongs to an application

Three decisions in that table save a lot of pain later:

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:

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:

  1. 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.
  2. 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.
  3. 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 asksQuery 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:

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:

PartBuild it whenBuy it when
CV parsingYour CVs come from one template or one countryCVs arrive in any layout, language or file type
Email sendingNever; use a sending serviceAlways
Interview schedulingYou only need a link to a calendar toolYou need panel scheduling across time zones
Job board distributionYou post to one or two boards by handYou post to many boards and need applications back
Background checksNeverAlways, 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:

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.

All articles