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

Indeed jobs API: a build guide with working code

A hands-on guide to getting Indeed job postings into your product: first authenticated request, cursor pagination in Python, a Node equivalent, deduplication, incremental sync, and the schema to store it in.

Dvir Atias

Dvir Atias

Founder, JobsPipe

You have decided you need Indeed job postings inside your product, and now you want the code. This post is the build, not the background: a first authenticated request, a paginated sweep in Python, a Node equivalent, deduplication, incremental sync, and the schema to land everything in. If you still want the full story of what happened to Indeed’s own API, read the Indeed API guide first and come back here to build.

Every example below runs against the JobsPipe API, which indexes Indeed’s public listings alongside 30+ other sources and returns them under one schema. It is the closest thing to an Indeed jobs API you can sign up for and start calling today, and the pagination, dedupe, and sync patterns transfer to any provider you pick.

What you can actually call in 2026

Indeed retired its public Publisher API and never shipped a replacement for data consumers. What Indeed documents today is employer-side: GraphQL surfaces for publishing jobs into Indeed, receiving applications back into an ATS, reporting candidate outcomes, and managing sponsored campaigns. There is also a publisher JavaScript plugin that embeds a search widget into a page, which renders HTML rather than returning data you can store.

So there is no first-party Indeed job API you can sign up for and read postings from. Any job search API Indeed developers actually ship on in 2026 is third-party: an index of Indeed’s public listings, or a scraper you maintain yourself. The full breakdown lives in the Indeed API guide, the history in the Publisher API postmortem, the four employer surfaces in Indeed partner APIs, what each route costs in Indeed API pricing, and the build-your-own tradeoff in scraper vs API. That is all the history this post will do.

Step 1: your first Indeed job search API call

Create a key at the signup page. Keys are issued instantly and look like jp_live_.... Send it as a bearer token against POST /v1/jobs/search, with every filter in the JSON body rather than the query string.

curl -X POST https://api.jobspipe.dev/v1/jobs/search \
  -H "Authorization: Bearer jp_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "source_or": ["indeed"],
    "job_title_or": ["backend engineer", "platform engineer"],
    "job_country_code_or": ["US"],
    "posted_at_max_age_days": 7,
    "limit": 25,
    "include_total_results": true
  }'

source_or is the filter doing the Indeed-specific work. Drop that one line and the identical query returns LinkedIn, Greenhouse, Workday, Lever and the rest under the same field names, which is the point of a unified job search API. Everything else here is ordinary: title keywords, country codes, a freshness window, and a page size.

The response has two top-level keys. metadata carries total_results, truncated_results and next_cursor. data is the array of normalized postings.

{
  "metadata": {
    "total_results": 1842,
    "truncated_results": 25,
    "next_cursor": "eyJvZmZzZXQiOjI1fQ"
  },
  "data": [
    {
      "id": "jp_7f3a9c21",
      "job_title": "Senior Backend Engineer",
      "company": "Acme Robotics",
      "company_domain": "acme.com",
      "location": "Austin, TX",
      "country_code": "US",
      "remote": false,
      "seniority": "senior",
      "date_posted": "2026-07-21",
      "min_annual_salary_usd": 160000,
      "max_annual_salary_usd": 210000,
      "final_url": "https://acme.com/careers/senior-backend-engineer"
    }
  ]
}

If you want to see the shape before you have a key, the same query body works against POST https://api.jobspipe.dev/v1/sandbox/jobs/search with no Authorization header at all.

Step 2: paginate the full result set in Python

One request gives you one page. To collect a whole query you follow metadata.next_cursor until it comes back null, passing the previous cursor as cursor on the next request. The free tier allows 2 requests per second, so the loop also needs to pace itself and back off on a 429.

import os
import time
import requests

ENDPOINT = "https://api.jobspipe.dev/v1/jobs/search"
HEADERS = {
    "Authorization": "Bearer " + os.environ["JOBSPIPE_API_KEY"],
    "Content-Type": "application/json",
}

MIN_INTERVAL = 0.5

BASE_FILTERS = {
    "source_or": ["indeed"],
    "job_title_or": ["backend engineer", "platform engineer"],
    "job_country_code_or": ["US"],
    "posted_at_max_age_days": 7,
    "limit": 25,
}


def fetch_page(payload, attempt=0):
    response = requests.post(ENDPOINT, json=payload, headers=HEADERS, timeout=60)
    if response.status_code == 429 and attempt < 5:
        time.sleep(2 ** attempt)
        return fetch_page(payload, attempt + 1)
    response.raise_for_status()
    return response.json()


def fetch_all(max_pages=40):
    cursor = None
    jobs = []

    for page in range(max_pages):
        payload = dict(BASE_FILTERS)
        payload["include_total_results"] = page == 0
        if cursor:
            payload["cursor"] = cursor

        started = time.monotonic()
        body = fetch_page(payload)
        jobs.extend(body["data"])

        cursor = body["metadata"].get("next_cursor")
        if not cursor:
            break

        elapsed = time.monotonic() - started
        if elapsed < MIN_INTERVAL:
            time.sleep(MIN_INTERVAL - elapsed)

    return jobs


if __name__ == "__main__":
    postings = fetch_all()
    print(len(postings), "Indeed postings collected")
    for job in postings[:5]:
        print(job["id"], job["job_title"], job["company"])

Three details worth keeping when you adapt this. The sleep measures elapsed request time first, so a slow page does not stack an extra wait on top of a delay you already paid. The 429 handler backs off exponentially instead of retrying immediately, which is the difference between recovering and being throttled harder. include_total_results is requested only on the first page, because the count does not change mid-sweep.

There is also an official Python SDK if you would rather not write the loop. pip install jobspipe, then client.jobs.iter() walks pages for you and stops when the API reports no more results.

from jobspipe import Jobspipe

client = Jobspipe()

for job in client.jobs.iter(
    source_or=["indeed"],
    job_country_code_or=["US"],
    posted_at_max_age_days=7,
    page_size=25,
    max_results=500,
):
    print(job.id, job.job_title, job.company)

Set page_size explicitly. The SDK defaults to 100, which is above the free plan ceiling of 25.

The same Indeed job API call from Node

Nothing about the TypeScript version is special. It is one fetch with a JSON body and a bearer header, so no client library is required.

type Job = {
  id: string;
  job_title: string;
  company: string | null;
  location: string | null;
  date_posted: string | null;
  final_url: string | null;
};

async function searchIndeedJobs(cursor?: string): Promise<{
  jobs: Job[];
  nextCursor: string | null;
}> {
  const response = await fetch("https://api.jobspipe.dev/v1/jobs/search", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.JOBSPIPE_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      source_or: ["indeed"],
      job_title_or: ["backend engineer"],
      job_country_code_or: ["US"],
      posted_at_max_age_days: 7,
      limit: 25,
      ...(cursor ? { cursor } : {}),
    }),
  });

  if (!response.ok) {
    throw new Error("jobs/search failed: " + response.status);
  }

  const body = await response.json();
  return { jobs: body.data, nextCursor: body.metadata.next_cursor ?? null };
}

Wrap that in the same while-loop over nextCursor and you have the Node equivalent of the Python sweep. Add your own delay between calls; the rate limit applies per key, not per language.

Deduplicating syndicated postings

One open role is rarely one posting. A company publishes to its ATS, the ATS syndicates to Indeed, a recruiter reposts it, and an aggregator picks it up again. Pull raw data from several sources and the same job lands in your table three or four times with slightly different titles and location strings.

The first line of defense is the id field. It is stable for a given posting across requests and across sources, so upserting on id collapses the mechanical duplicates without any fuzzy matching from you. Do this before anything clever.

What id will not collapse is two genuinely separate postings for the same underlying role. For those, group on normalized company plus normalized title, restricted to a location and a date window: lowercase both fields, strip punctuation and seniority noise, and treat rows that collide inside a two-week window as one role with several listings. Keep every row and mark a canonical one rather than deleting, because you cannot recover a posting you threw away and merging rules always need tuning.

Incremental sync: pull only what is new

Re-fetching your whole corpus every night burns quota to rediscover rows you already have. Use the date filters instead. posted_at_max_age_days is the simple version: set it to 1 or 2 and you get yesterday’s postings only. When you need exact boundaries, posted_at_gte and posted_at_lte take YYYY-MM-DD dates.

{
  "source_or": ["indeed"],
  "job_country_code_or": ["US"],
  "posted_at_gte": "2026-07-24",
  "posted_at_lte": "2026-07-25",
  "limit": 25
}

Run that on a schedule and cursor through it exactly like the full sweep. A narrow window paginated to exhaustion beats a wide window you truncate, because truncation silently drops postings and you will never know which ones. Overlap the windows by a day to absorb postings that get indexed late.

Incremental sync tells you what appeared. It does not tell you what disappeared, and stale rows are the thing users notice first. Plan a separate liveness pass; the mechanics are in how to find expired job postings.

Storing it: the minimum schema

You need seven columns to do anything useful and one more to stay out of trouble later. Postgres, but the shape is the same anywhere.

create table job_postings (
  id                text primary key,
  title             text not null,
  company           text,
  location          text,
  country_code      text,
  remote            boolean,
  min_annual_salary integer,
  max_annual_salary integer,
  salary_currency   text,
  posted_at         date,
  apply_url         text,
  source            text not null,
  first_seen_at     timestamptz not null default now(),
  last_seen_at      timestamptz not null default now(),
  raw               jsonb not null
);

create index job_postings_posted_at_idx on job_postings (posted_at desc);
create index job_postings_dedupe_idx
  on job_postings (lower(company), lower(title), country_code);

Map final_url into apply_url; it is the resolved destination after redirects, which is what you want behind an apply button. source is yours to set from the filter you queried with. Compensation arrives as a range rather than a single number, and plenty of postings do not state one at all, so both salary columns must be nullable and your UI needs a no-salary case.

The raw column matters more than it looks. Store the full JSON payload for every posting even though you only read eight fields today. When you decide next quarter that you want technology_slugs, seniority, or the full description, the data is already sitting in your database instead of costing you a re-crawl of postings that may have closed by then.

Rate limits and quota math

Do this arithmetic before you write the ingestion job, not after it starts returning 402s. Requests, not rows, are the metered unit, so page size is what actually decides how much data a plan buys you.

PlanRequests / monthRate limitMax page sizePostings / month ceiling
Free1002 / sec252,500
Builder100,00010 / sec10010,000,000
Scale1,000,00050 / sec500500,000,000

Free is a prototype budget, and it is worth being blunt about what it buys. 100 requests at 25 results each is 2,500 postings for the entire month, and spending all of them in one sweep takes about 50 seconds at 2 requests per second. Split across a daily job that is roughly 3 requests and 75 postings per day. Enough to validate your mapping code and ship a demo, not enough to keep a job board fresh.

Builder is where production starts, and the jump is larger than the request count suggests because page size quadruples at the same time. A daily sweep collecting 20,000 Indeed postings costs 200 requests per day, about 6,000 per month, which is 6% of the allowance. That leaves the rest for per-user searches, backfills, and the liveness pass. Current numbers are on the pricing page.

Common mistakes

  • Paging with offset on large sweeps. offset and page exist and are fine for a UI that jumps to page 4. For walking an entire result set, use cursor. Deep offsets get slower the further you go, and new postings arriving mid-sweep shift the window under you, so rows get skipped or repeated.
  • Ignoring the rate limit. Firing pages concurrently because it is faster locally will 429 the moment you point it at a real corpus. Pace the loop and back off exponentially. A sweep that takes two minutes and finishes beats one that takes twenty seconds and dies at page nine.
  • Keying on the apply URL instead of the ID. Apply URLs get tracking parameters appended, redirect targets change, and two boards give the same job two different links. Use id as your primary key and treat the URL as a display field that can change under you.
  • Filtering on seniority without expecting gaps. Most postings never state a level, so a strict job_seniority_or filter quietly removes the majority of your results. Filter on it late, in your own UI, rather than at ingestion time.

Where to go next

You now have the whole path: authenticated request, cursor pagination, dedupe on id, a dated incremental sweep, and a table with the raw payload preserved. Swapping source_or from indeed to any other value, or removing it entirely, is the only change needed to widen this from an Indeed integration to a multi-source one, which is usually the second thing teams want.

Full parameter and field reference is at docs.jobspipe.dev, and if you are wiring this into an agent rather than a backend there is an MCP server that exposes the same search.

Get a free key and run the first curl in this post against live Indeed postings in under a minute.

Get a free API key

Frequently asked questions

Does Indeed have a jobs API?

Not one that returns job postings to developers. Indeed retired its public Publisher API and the APIs it documents today are employer-side: publishing jobs into Indeed, receiving applications back into an ATS, reporting candidate outcomes, and managing sponsored campaigns. All of them require partner approval and all of them move data toward Indeed rather than out of it. To read Indeed postings programmatically you need a third-party API that indexes Indeed's public listings.

Is there a free Indeed job search API?

There is no free first-party one, because Indeed does not issue self-serve data credentials at any price. Third-party APIs that index Indeed listings do have free tiers: JobsPipe's free plan is 100 requests per month at 2 requests per second and a maximum page size of 25, which works out to 2,500 postings a month. There is also a no-key sandbox endpoint at POST https://api.jobspipe.dev/v1/sandbox/jobs/search if you only want to inspect the response shape.

How do I get Indeed job data into my app?

Send a POST to a job search API with a source filter set to Indeed, page through the results with a cursor until the API stops returning one, and upsert each posting into your database keyed on its stable ID. With JobsPipe that is POST https://api.jobspipe.dev/v1/jobs/search with a bearer token, source_or set to indeed, and a loop following metadata.next_cursor. Store the full raw JSON payload alongside the columns you use today so you do not have to re-fetch when you need more fields later.

Can I use the Indeed API to search jobs?

No. None of Indeed's current partner APIs exposes a job search endpoint to data consumers, so even an approved partner credential will not let you query postings. The publisher JavaScript plugin embeds a search widget in a page, but it renders HTML rather than returning structured data you can store or filter server-side. Searching Indeed postings programmatically means using an API that indexes Indeed's public listings.

What is the best Indeed API alternative for reading postings?

The practical alternatives are a unified jobs API that indexes Indeed alongside other boards, or your own scraper. An API is faster to ship and removes proxy spend and parser maintenance; a scraper gives you full control at the cost of ongoing engineering. JobsPipe covers Indeed plus 30+ other sources including LinkedIn, Workday, Greenhouse, and Lever under one normalized schema, so the same request shape works whether you are pulling one source or all of them.

How do I paginate a job search API?

Use cursor pagination for full sweeps and offset only for UIs that jump to a specific page. Send your filters, read metadata.next_cursor from the response, pass it back as the cursor parameter on the next request, and stop when it comes back null. Cursors stay correct when new postings arrive mid-sweep, whereas deep offsets shift the window and cause rows to be skipped or repeated.