NewSearch millions of jobs from your AI agent with MCP
All posts
Guide·Jul 25, 2026·8 min read

An API for job search: the architecture behind a search box that returns real jobs

What has to happen between a user typing into a box and getting good jobs back: query understanding, filters, ranking, cursor pagination, caching, and the empty state everyone gets wrong.

Dvir Atias

Dvir Atias

Founder, JobsPipe

A search box is two lines of UI and a genuinely hard backend. Someone types senior react remote and expects a page of jobs that are still open, at roughly their level, not tied to an office they cannot reach, and posted recently enough that a human is still reading applications. Not one of those expectations is a keyword match.

This post is about what happens between the keystroke and the result set. If what you want is the integration walkthrough - get a key, make the first call, wire up a cursor loop - that is the job search API integration guide, and it is the shorter read. This one is about relevance, ranking, filters, pagination, latency, and empty states.

Generic search assumes the query and the corpus are written in the same language. In job search they are not, in three specific ways.

Users do not type posted titles. They type swe, front end dev, pm. Employers post Software Engineer III, Frontend Engineer (React), and Technical Program Manager, Platform. The two vocabularies overlap far less than you would guess.

Location is four questions wearing one token. A user typing London might mean in London, commutable to London, remote but legally employable in the UK, or hybrid with two days in the office. One string has to resolve into a radius, a country, a remote flag, and a hybrid flag, and the right answer depends on facts they never told you.

Recency dominates in a way it does not elsewhere. A five-week-old posting is often already filled. A stale result is not slightly worse; it is a wasted application and a hit to your credibility. Very few verticals decay this fast.

Query understanding: turning a string into filters

The layer between the box and the API does three jobs: normalize the title, extract the modifiers, and split location tokens from skill tokens. Everything downstream is only as good as this step.

Title normalization and synonym expansion. You need a table mapping what people type to what employers post. The public starting point is the O*NET Alternate Titles file, which maps lay occupational titles to standard occupation codes and exists specifically to improve keyword search. It is a decent seed, weak on modern software titles, so hand-curate the few hundred queries carrying most of your volume.

Seniority extraction. Strip level words out of the title token before you match on it and route them to the seniority filter instead. The literal string senior backend engineer misses Backend Engineer II and Staff Backend Engineer, both of which the user wanted.

Location versus skill. Some tokens are both. Jakarta is a city and a Java framework; Java is an island. Check the token against a gazetteer, prefer the skill reading when the query already has a location token, and never resolve silently. Pair job_location_or with job_country_code_or to disambiguate same-named cities, and render your interpretation as an editable chip.

Here is the dividing line. A jobs API gives you declarative OR and NOT lists on title, description, company, and country, which covers synonym expansion and negative matching without any scoring code of your own. The mapping from a typed string to those lists is your layer, and it is where search quality lives.

{
  "job_title_or": [
    "software engineer",
    "backend engineer",
    "backend developer",
    "platform engineer"
  ],
  "job_title_not": ["intern", "manager", "director", "sales"],
  "limit": 25
}

job_title_not is not cosmetic. A title query for engineer drags in sales engineers and engineering managers, and developer drags in business development. Negative matching removes a whole class of embarrassing results for almost no effort, and most implementations skip it.

The filters people actually use, and what each one costs

Every filter is a promise that you understood the user, and also a cut that can empty the result set.

FilterParameterUX consequence
RemoteremoteA hard cut, not a preference: checking it loses every hybrid role the user would have taken. Records carry both a remote and a hybrid flag, so offer three states, not a checkbox.
Countryjob_country_code_orThe cheapest large cut and almost always correct, since people rarely want results they cannot legally accept.
Recencyposted_at_max_age_daysThe highest-leverage quality dial you have. Seven days feels sharp and empty, thirty feels full and stale. Fourteen is a good default.
Seniorityjob_seniority_orThe most damaging filter if applied naively. Roughly 55% of postings never state a level, and a seniority filter excludes every one of them by default.
Companycompany_name_partial_match_orUsers type Google, the posting says Google LLC. Use partial matching for anything a human typed; keep company_name_or for exact names you control.

A realistic multi-filter query against the search endpoint:

curl https://api.jobspipe.dev/v1/jobs/search \
  -H "Authorization: Bearer jp_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "job_title_or": ["backend engineer", "software engineer"],
    "job_title_not": ["intern", "manager"],
    "job_country_code_or": ["US", "CA"],
    "job_location_or": ["Austin", "TX"],
    "posted_at_max_age_days": 14,
    "job_seniority_or": ["senior"],
    "order_by": [{"field": "posted_at", "desc": true}],
    "include_total_results": true,
    "limit": 25
  }'

One thing that query cannot express: filters combine with AND, so there is no way to ask for “in Austin OR fully remote” in one call. That intent is a union, and unions are two calls merged on the stable id, which is the same across sources for the same role. Users ask for unions constantly, so build the merge once.

Ranking: recency is the honest default

In most verticals relevance ranking beats date ordering and it is not close. Job search is the exception often enough that most-recent is the right default, because value decays fast and a dead posting is expensive. Sorting by posted_at descending is a defensible product decision, not a lazy one.

Relevance wins on broad queries. A search for engineer across the US matches an enormous number of postings, and ordering that by date hands the user a random sample of whatever was published this morning. Broad queries need scoring or they are unusable.

Now the trap. Ranking purely on keyword score fails badly here, because job descriptions are written to be found. A posting listing forty technologies outscores the one naming the three that matter, on any scorer that rewards term frequency, so pure keyword ranking systematically promotes the worst-written postings. Weight a title match far above a description match, cap what any single field can contribute, and normalize by length. BM25 does some of this and it is not enough when the stuffing is permanent.

The blend that works is a relevance score multiplied by an exponential recency decay, half-life in the one to two week range, tuned against your own click data. Apply the decay over an already-filtered set rather than as a tiebreak on the raw index, and keep a hard floor with posted_at_max_age_days. Then expose the sort control and let the user flip it.

Pagination at scale

Both models exist for different jobs. offset and page give you numbered page links and are fine for the first handful of pages. cursor, paired with next_cursor from the metadata, is what you want everywhere else.

Offset degrades structurally: the engine still produces and discards every row before your offset, so page cost grows with depth while page size stays constant. A cursor encodes a position in the sort order, so the engine seeks straight to it and page two hundred costs what page two costs. Use it for infinite scroll, exports, backfills, and anything an AI agent drives.

The subtler problem is stability. The index changes underneath a paging user as new postings land and filled ones close. With offset, one posting inserted above the window shifts everything down by one and the last row of page one reappears at the top of page two; deletions silently skip a job. A cursor is anchored to the data rather than to a row count, so that shift produces neither duplicates nor gaps.

Two rules. Cap the depth: if someone is on page forty the query was wrong, and the fix is better filters. And set include_total_results only on the first page, because counting every match is real work and you need the number once for the header.

Latency and caching

Search has to feel immediate, and perceived latency is mostly under your control regardless of the upstream call. Render the shell and a skeleton on first paint, run the search server-side, and never block the page on the total-results count.

Cache what is expensive, low-cardinality, and slow to change: facet counts, autocomplete dictionaries, your synonym table, and the landing-page queries that make up your SEO surface. Those can sit in cache for hours.

Never cache anything that decides whether a specific job is still open. A cached list showing dead postings is worse than a slow one: slow costs the user seconds, dead costs them an application and costs you their trust. That asymmetry should drive the whole design.

Which tells you where the boundary goes. Cache the mapping from a normalized query to a list of job IDs and hydrate the bodies fresh. IDs are tiny and stable; the bodies carry the status field that says whether the role is active, so a job that closed drops out on the next hydrate even when the ID list is minutes stale. Key on the normalized query, not the raw string, so Remote Python and remote python are one entry.

Empty states and query relaxation

Zero results is common in job search, because users over-filter and narrow intersections are genuinely thin. Most implementations return an empty list and a shrug, which makes it the worst moment in the product and the easiest thing on this page to fix.

Relax progressively, one step at a time, re-running until you get results, in a fixed order from least to most damaging.

  • Widen recency: fourteen days to thirty to ninety.
  • Drop the seniority filter, which was silently excluding unlabeled postings anyway.
  • Add the next tier of title synonyms to job_title_or.
  • Widen location from city to region, then add remote roles in the same country.
  • Drop the company filter.

Never relax country. Nobody can take a job they are not authorized to work, and a result set quietly padded with foreign roles reads as a broken product.

What matters more than the ladder is making the relaxation visible. “No exact matches. Showing 18 senior backend roles from the last 90 days instead”, with each relaxed filter rendered as a removable chip. Silent relaxation is a bug report waiting to happen, because the user believes they are looking at exactly what they asked for.

Semantic search: what it fixes and what it does not

Embeddings genuinely solve vocabulary mismatch. A posting says Golang and the user typed Go; the posting says Kubernetes and the user typed k8s. Keyword matching has no path to those pairs and a vector space does. They are also the only workable answer to long natural-language queries like “a remote backend role at a seed-stage company where I would own infrastructure”.

They underperform on short queries, which is most queries. Two words carry too little signal, and nearest-neighbour retrieval returns things that are topically adjacent but wrong, like a Kotlin Android role for a Kotlin backend engineer. Country, remote status, and posting date are also facts rather than similarities, and a vector index will cheerfully hand a Berlin job to a US-only query.

So use the same funnel as candidate matching: filter first, vectors for recall inside what remains, then rerank. Those mechanics are in how AI job matching actually works, with one caveat for search, which is that a query is far shorter than a profile, so the filters carry more weight. When the model writes the filters itself, that is a different architecture again, covered in building an AI job search agent. Either way, semantic search is an addition to a well-filtered keyword system, not a replacement for one. Ship the filters first.

One endpoint, 30+ sources, normalized - start free and put a real search box behind it.

Get a free API key

Frequently asked questions

What is an API for job search?

An API for job search is a single HTTP endpoint that takes filters like job title, country, remote status, seniority, and posting age, and returns matching job postings in one normalized schema. It replaces the work of scraping and parsing dozens of job boards and applicant tracking systems yourself. JobsPipe serves this at POST https://api.jobspipe.dev/v1/jobs/search, indexing 30+ sources and returning a metadata block plus a data array of postings with a stable ID and an apply URL.

How do I build a job search feature?

Wire the API call first, then spend your effort on the layer in front of it. That layer turns a typed string into filters: normalizing the title and expanding synonyms, extracting seniority into its own filter, and separating location tokens from skill tokens. The API gives you OR and NOT lists on title, company, and country declaratively, but the mapping from what a user types to those lists is yours to build, and it is where search quality actually comes from.

How should job search results be ranked?

Most recent first is the honest default, because a job posting loses value fast and a filled role is worse than no result at all. Switch to relevance ranking on broad queries, where date ordering just returns a random sample of that morning's postings. Avoid ranking purely on keyword score: job descriptions are written to be found, so a posting listing forty technologies will outrank the one naming the three that matter. Blend a relevance score with an exponential recency decay and keep a hard recency floor underneath it.

What is the best API for job search?

The best API for job search is the one that covers the sources your users care about, normalizes them into a single schema, and exposes the filters your search box needs: title OR and NOT lists, country, remote, seniority, and posting age. JobsPipe covers 30+ sources including Indeed, LinkedIn, Workday, Greenhouse, Lever, and Glassdoor behind one endpoint, with a free tier of 100 requests per month and a no-key sandbox for evaluation.

Should job search use cursor or offset pagination?

Use cursor pagination for anything past the first few pages. Offset makes the engine produce and discard every row before your offset, so page cost grows with depth, while a cursor seeks straight to a position in the sort order and page 200 costs what page 2 costs. Cursors are also stable: when new postings land while someone is paging, offset shifts rows and produces duplicates or gaps, and a cursor anchored to the data does not. Offset is still the right choice when you need numbered page links.

Does semantic search work for job listings?

It works well for vocabulary mismatch and long natural-language queries, and poorly for short ones. Embeddings connect Go to Golang and k8s to Kubernetes, which keyword matching cannot do, but a two-word query carries too little signal and nearest-neighbour retrieval returns topically adjacent results that are wrong. Country, remote status, and posting date are facts rather than similarities, so filters must still run first. Treat semantic search as an addition to a filtered keyword system, not a replacement for one.