Indeed has no self-serve API, so job searches, postings and company ratings all come from its public pages. Those pages sit behind Cloudflare, and Indeed decides on the IP before it sends any content. On 27 October 2026 every first-page search, job and company page we sent through a residential proxy came back with content, and in our probe runs the day before, every datacenter request got Indeed's security check. This guide covers what we measured, the structured route through the Indeed Scraper API, the do-it-yourself route through the embedded JSON, and the limits you can't get around.
This post was rewritten on 27 October 2026. The July 2026 version reported every proxy tier blocked, homepage included. Residential traffic gets through again, and the numbers below are from the new tests.
Key Takeaways
- There is no public Indeed API. The Publisher API closed to new applicants in October 2022 and was retired. The Indeed Apply and Sponsored Jobs APIs that remain are partner-only and push jobs into Indeed, not out.
- Residential proxies get through; datacenter IPs don't. In our October tests, search, job and company pages loaded through residential exits, typically in 2–3 seconds and up to about 9. Datacenter IPs got a 403 on every request, and so did HTTP clients with a non-browser TLS fingerprint.
- The data is embedded JSON, not CSS classes. Search cards are in
mosaic-provider-jobcards, a job in_rootProps.preloadedVJDataplus JSON-LD, and a company incomp-initialData. - Only the first page of a search is public on the website. From
start=10on, Indeed asks you to sign in. /datareturns it all as JSON for 15 credits a page: searches of up to 100 jobs a page (several hundred per search), postings and company pages. Salary pages and company tabs go through the AI-learned path instead.
Does Indeed have an API?
No, not one you can sign up for.
Indeed's Publisher API was the standard route for job-board aggregators for years. It stopped accepting new publishers in October 2022 and was later retired. What remains under partners.indeed.com are the Indeed Apply and Sponsored Jobs APIs. Those serve employers and ATS vendors pushing jobs into Indeed, not developers pulling search results out. Access goes through a partnership process, not a signup form, and it only covers your own postings.
Every "Indeed API" you find on a marketplace today is a third-party scraper with an API wrapper. Each one faces the same limits described below.
Can you scrape Indeed in 2026?
Yes, from residential IPs. These are real requests through WebScraping.AI on 27 and 28 October 2026:
| Request | Endpoint | Result |
|---|---|---|
Search: nurse, Chicago, IL | /data | 200 in 4.7 s: 100 jobs of 7,818 |
The same search, start=100 | /data | 200 in 1.6 s: the next 100 jobs |
Search on uk.indeed.com: software engineer, London | /data | 200 in 1.2 s: 100 jobs of 23,072 |
Job posting /viewjob?jk=… | /data | 200 in 0.3–1.0 s: full description, date, apply link |
Company /cmp/Starbucks | /data | 200 in 2.5 s: 3.8 stars from 75,388 reviews |
| Search page | /html, proxy=residential | 200 in 2.9 s, 1.6 MB of HTML |
| Search page | /html, proxy=datacenter | 400, not charged: Indeed needs proxy=residential |
Our longer probe runs from the same week show which clients get through:
- Datacenter IPs fail every time with Cloudflare's "Security Check - Indeed.com" page.
- On residential IPs, the TLS fingerprint decides. On the same residential pool, curl-impersonate with Chrome targets got 16 of 20 searches and 9 of 20 company pages through. A Node HTTP client with its own TLS stack got 0 of 20 and 1 of 20. Real Chromium with JavaScript off did about as well as curl-impersonate, and slower.
- Job pages (
/viewjob) are walled hardest. On that pool, no client we tried got one through. A search page withvjk=<jk>carries the same job and passed 12 of 20 times on the same pool (see below).
Indeed's posture has loosened and tightened before: in July 2026 all of these were blocked. Results from your own pools, countries and vendors will differ, so expect it to change again. A blocked /data request is refunded, so you can re-test any time. On /html you pay for a block page that comes back as a 200, so check the title (see below). Our guide to proxy types covers the residential-versus-datacenter decision in general.
Scrape Indeed with the /data endpoint
The /data endpoint takes the normal Indeed URL you'd open in a browser and returns structured JSON. It picks the fetch route and retries blocks on fresh exits, so none of the walls below are your problem. It knows three page types:
- Search:
indeed.com/jobs?q=…&l=…, the SEO pages such as/q-python-l-remote-jobs.html, and the same on country sites. - Job posting:
/viewjob?jk=…,/rc/clk?jk=…, or any search URL withvjk=. - Company:
/cmp/<name>.
A pipeline that pages through a search, then fetches every posting in full:
import requests
API_KEY = "YOUR_API_KEY"
def indeed(url):
"""The parsed page, or None if it was blocked (refunded) or parsed empty."""
resp = requests.get(
"https://api.webscraping.ai/data",
params={"api_key": API_KEY, "url": url},
timeout=120,
)
if not resp.ok: # blocks and timeouts are refunded
print(url, resp.status_code, resp.json().get("message"))
return None
body = resp.json() # parse_status: "ok", "parse_failed" or "not_found"
return body["data"] if body["parse_status"] == "ok" else None
url = "https://www.indeed.com/jobs?q=nurse&l=Chicago%2C+IL"
while url: # 100 jobs per page
search = indeed(url)
if not search:
break
for card in search["jobs"]:
print(card["job_title"], "|", card["company_name"], "|", card["salary_formatted"])
job = indeed(card["url"])
if job:
print(job["job_type"], job["benefits"], len(job["description"] or ""))
url = search["next_page_url"] # None on the last page
A search card from that run, trimmed to the main fields:
{
"job_id": "4b64fb9e9815becd",
"job_title": "Registered Nurse - Dialysis",
"company_name": "Comprehensive Kidney Care",
"company_rating": 2,
"location": "Chicago, IL 60630",
"salary_formatted": "$43 - $50 an hour",
"salary_range": {"min": 43, "max": 50, "currency": "USD", "period": "hour"},
"job_type": "Part-time",
"attributes": ["Dialysis"],
"date_posted": "30+ days ago",
"url": "https://www.indeed.com/viewjob?jk=4b64fb9e9815becd"
}
Use job_id (Indeed's jk) as the primary key for deduplication. The same job shows up in many searches. Search results are Indeed's organic jobs; sponsored listings aren't included.
A job posting adds the full description (description as plain text, job_description_formatted as HTML), company_website, shift_schedule, benefits, indeed_apply, apply_link (the employer's own apply page), and srcname (the employer, an agency or a job board). Field names mostly follow Bright Data's Indeed dataset (job_id is their jobid). A company page returns the overview, the five category ratings, CEO approval, popular salaries and job titles, and the ~5 reviews the page shows. The Indeed Scraper API page lists every field.
Each page costs 15 credits. A block or a timeout is refunded. A page that parses empty (parse_failed) or no longer exists (not_found) is charged, because the fetch itself succeeded.
Salaries
Salaries are the field most people come to Indeed for. In our Chicago nurse search, 67 of 100 jobs carried a salary, and 75 of 100 in a remote Python search. Each one returns as Indeed's own text (salary_formatted) and as salary_range with min, max, currency and period, so hourly and yearly pay can go in one column after a conversion. Cards without a listed salary return null for both.
Indeed's salary guides (/career/<title>/salaries) aren't one of the three parsed types. /data still takes them through its AI-learned path, which works out the page's structure on the first request and reuses it after that. Our request for /career/software-engineer/salaries returned (trimmed):
{
"job_title": "Software Engineer",
"location": "United States",
"average_base_salary": 136126.0,
"low_salary": 79565.0,
"high_salary": 232894.0,
"salaries_taken_count": "38.1k salaries",
"highest_paying_cities": ["San Jose, CA", "New York, NY", "Seattle, WA", "Los Angeles, CA", "Redmond, WA"]
}
That path is priced per site rather than at the flat 15 credits. Company tabs (/cmp/<name>/reviews, /salaries, /jobs) go the same way. For a salary benchmarking dataset, salaries from postings and from the salary guides answer different questions. The guides average what people report. Postings show what employers offer this week.
Country sites
uk.indeed.com, ca.indeed.com, de.indeed.com and the other country sites work the same way: you get that site's jobs, with salaries in its currency and tags in its language. Our London search came back in pounds:
{"salary_formatted": "£68,000 - £87,500 a year",
"salary_range": {"min": 68000, "max": 87500, "currency": "GBP", "period": "year"}}
Getting past the first page
On the website, Indeed shows search results past the first page only to signed-in visitors. start=10, 20 and later got the security check on every exit we tried, with or without a page-one referer. With page one's cookies on the same sticky IP, page 2 redirected to "Sign In | Indeed Accounts". So a scraper that reads the HTML gets about 15 organic jobs per search, plus the sponsored ones.
/data isn't limited to that page. It returns 100 jobs a page and next_page_url links the next one. start= counts jobs, as on Indeed, so start=100 is the second page here. Indeed stops listing a search after several hundred jobs (717 for our Chicago nurse search, which Indeed counts at 7,818), so for a bigger result set, split it into narrower searches. Deduplicate on job_id either way: consecutive pages can share a job or two.
from urllib.parse import urlencode
cities = ["Austin, TX", "Dallas, TX", "Houston, TX", "San Antonio, TX"]
seen = {}
for city in cities:
url = "https://www.indeed.com/jobs?" + urlencode(
{"q": "data engineer", "l": city, "fromage": "1", "sort": "date"}) # posted in the last day
search = indeed(url)
for card in (search or {}).get("jobs", []):
seen.setdefault(card["job_id"], card)
print(len(seen), "unique jobs")
sort=date with fromage=1, run daily, is the cheapest way to track new postings: one page holds a day's 100 newest jobs. If a search has more than that in a day, follow next_page_url or split it again by city or job type.
Doing it yourself: the embedded JSON
To parse Indeed's pages yourself, fetch them with /html and proxy=residential. Pass js=false. Indeed's pages are server-rendered and /html fetches them without a browser either way, so JavaScript rendering would only raise the price from 10 credits a page to 25. Then skip the HTML cards. Indeed's class names churn, but the JSON blobs have stayed put:
- Search pages:
window.mosaic.providerData["mosaic-provider-jobcards"]. The cards are undermetaData.mosaicProviderJobCardsModel.results, each withjobkey,displayTitle,company,formattedLocation,extractedSalary,salarySnippet,pubDateand about 100 other keys. - Job pages:
window._rootProps.preloadedVJData, with the posting underjobInfoWrapperModel.jobInfoModel. ExactdatePostedandvalidThroughdates are only in the page'sJobPostingJSON-LD. - Company pages: the
<script id="comp-initialData">element.
import json, re, requests
html = requests.get(
"https://api.webscraping.ai/html",
params={
"api_key": "YOUR_API_KEY",
"url": "https://www.indeed.com/jobs?q=python+developer&l=Remote",
"proxy": "residential",
"js": "false",
},
timeout=60,
).text
match = re.search(r'window\.mosaic\.providerData\["mosaic-provider-jobcards"\]=(\{.+?\});', html)
if not match: # a block page, or a search with no results
raise SystemExit(re.search(r"<title>(.*?)</title>", html).group(1))
cards = json.loads(match.group(1))["metaData"]["mosaicProviderJobCardsModel"]["results"]
for card in cards:
salary = (card.get("salarySnippet") or {}).get("text")
print(card["jobkey"], card["displayTitle"], "|", card["company"], "|", salary)
Check for walls before you parse. A blocked request can come back as a 200 whose <title> is "Security Check - Indeed.com", "Authenticating..." or "Sign In | Indeed Accounts". Match the whole title, not a prefix: a real search for "security checkpoint screener" has a title that starts the same way. A search with no results has no mosaic-provider-jobcards blob at all, so don't count that as a parse failure. /viewjob is where this gets hard. It's walled more often than search, and in a full browser it tends to land on the sign-in page. A search URL with vjk=<jk> renders the same job object in its side pane and gets through more often. /data has its own route for job pages.
Which route to pick
- Job market analytics, salary bands, hiring signals:
/dataon narrow Indeed searches, run daily withsort=date, plus postings for the full description. Add the salary guides for reported pay. - A job board or aggregator:
/dataon narrow searches for discovery, then each posting for the full description, deduplicated onjob_id. - Employer reputation:
/dataon/cmp/<name>for ratings, CEO approval and the latest reviews. The full reviews tab goes through the AI-learned path. See company reviews monitoring. - Your own parser:
/htmlwithproxy=residentialandjs=false, the JSON locations above, and a wall check before every parse.
WebScraping.AI's free tier is 2,000 credits a month with no credit card. That covers 133 Indeed pages through /data, enough to run the pipeline above against your own searches before you pay for anything. The docs have the full endpoint reference.