Blog / Web data by industry

Job Posting Data for Labour-Market Research

Job postings show what employers want as soon as they advertise a role: titles, locations and dates are public at once, with no survey cycle to wait for. Three fields carry most of the trend analysis: title, location and date_published. Postings measure demand, not hiring, so treat every series as an index of advertised openings. One call to the Indeed jobs endpoint returns these fields as JSON for 1 credit, and a repeatable weekly collection fits in a few thousand credits a month.

Which posting fields matter for trend analysis?

Five fields do most of the work: title, location, date_published, id and company.name. Title tells you the role, location tells you where, the date tells you when it was advertised, the id lets you count each posting once, and the company lets you see whether a few employers drive a trend. Everything else is useful context but rarely the unit you count.

The table below maps fields from the recorded response to the job they do.

Field Type in the sample Use in research
id string Deduplicate postings across runs and pages
title string Classify the role; normalise before counting
company.name string Concentration check: how many employers per role
location.city, location.state, location.country strings Regional counts and cross-country comparisons
location.is_remote boolean Remote share of postings
location.latitude, location.longitude numbers Mapping and commuting-zone joins
date_published string, 2026-08-12 05:00:00 Time axis; the posting's age when you collected it
attributes list of strings Employment type and skill tags, e.g. Full-time, UI design
description_html string, HTML Text mining for skills; strip the markup first

Be careful with attributes. In the recorded sample it mixes employment type ("Full-time") with skill-like tags ("UI design", "Go", "Collaboration with Sales"), so it's a signal to test, not a clean taxonomy. For employment type, filter on the job_type request parameter instead of parsing tags.

What about salary?

Pay is the field researchers ask for most, and the recorded response doesn't contain a salary field. The attributes list in the sample holds no pay figures either. Run your own queries, inspect the JSON, and count how many postings carry any pay information before you build a series on it. If coverage is thin or uneven by country, say so in your write-up rather than imputing values.

How do you pull the data?

You send GET /v1/indeed/jobs with a required query and optional filters for place, type and ordering. For a time series, set sort to date and spot-check the ordering on your own queries, then follow cursor until has_more is false or you hit your page budget. Each successful call costs 1 credit, and failed calls cost nothing.

curl "https://api.perceptlayer.com/v1/indeed/jobs?query=software+engineer&location=Berlin&country=DE&job_type=full_time&limit=20" \
  -H "X-API-Key: $API_KEY"
Parameter Type Notes
query string, required Up to 500 characters
location string Up to 200 characters
country string Up to 2 characters, e.g. DE
radius, radius_unit integer, string radius_unit is MILES or KILOMETERS
job_type string full_time, part_time, contract, internship, remote
sort string relevance or date
limit integer Postings per call
cursor string From pagination.next_cursor
hl, gl string Up to 2 characters each

The recorded response for that request returns a jobs array and a pagination object. An excerpt:

{
  "success": true,
  "data": {
    "jobs": [
      {
        "id": "805a70464f904dce",
        "title": "Senior Product Manager (Enterprise)",
        "company": { "name": "n8n", "profile_url": "/cmp/N8n" },
        "location": { "city": "Berlin", "country": "DE", "is_remote": true, … },
        "attributes": ["Collaboration with Sales", "Go"],
        "date_published": "2026-08-12 05:00:00",
        …
      }
    ],
    "pagination": { "next_cursor": "ABQAAYEAFAAAAAAAAAAAAAAAAmYRNdcAMgEBAQcpfSGrk7wi9QBuLZ6BEihQwmDA0xA2nDAGrqtuJIdLnPZKyLE0IcnT2+IETfyRAAA=", "has_more": true },
    "metadata": { "total_results": 20, "timestamp": "2026-10-21T13:25:37.233649Z" }
  }
}

For the endpoint basics, see Indeed Jobs API: Get Job Postings as JSON. The Indeed API: Jobs page lists the endpoint, and the playground on the Indeed API page runs a real call with your key.

Do job postings measure hiring?

No. A posting records that an employer advertised a role, not that anyone was hired. The US Bureau of Labor Statistics counts both separately in its Job Openings and Labor Turnover Survey: in August 2026 it reported 7.1 million job openings and 5.2 million hires (BLS, JOLTS news release, 2026). The gap between the two is the first thing to keep in mind when reading a postings chart.

Several biases follow from that, and each changes how you should read a trend.

  • Duplicates and reposts. The same role can appear more than once. Count by id, and consider also collapsing on company, normalised title and city.
  • Ghost and evergreen postings. Some listings stay up with no intent to fill them soon. A rising count can mean more advertising, not more demand.
  • Channel coverage. One site's postings over-represent the employers and occupations that use it. Small firms and informal hiring are under-counted.
  • Multiple hires per posting. One posting can fill several seats, and many hires never involve a posting.
  • Query shape. Keyword search returns what matches the words, so a title that changes name drops out of your series.

None of this makes postings useless. It means the right claim is "advertised demand for X rose" and the right check is a comparison with an official series. JOLTS draws on a survey of about 21,000 establishments (BLS, JOLTS Handbook of Methods, 2026), so it's a sound benchmark for direction, though not for individual roles or cities. JOLTS covers the US only, so it can't check a Berlin panel. For Germany and other EU countries, Eurostat publishes quarterly job vacancy statistics collected from national statistical authorities, and reported that 2.0% of jobs in the EU were vacant in Q2 2026 (Eurostat, Job vacancy statistics, 2026). Compare the direction of your index with that series, not the level.

How do you build a repeatable collection plan?

Fix a panel of queries and run it on a schedule that never changes. A panel is a list of title and location pairs, such as 25 roles across 10 cities. Each run pages through a set number of calls per pair, saves the raw JSON with a timestamp, and deduplicates by id at analysis time. Consistency matters more than breadth, because trend breaks come from changing the panel.

Here's a minimal Python collector. It saves each page as a JSON line and stops after a fixed page budget.

import json, os, time, datetime, requests

API = "https://api.perceptlayer.com/v1/indeed/jobs"
HEADERS = {"X-API-Key": os.environ["API_KEY"]}
PAIRS = [("software engineer", "Berlin", "DE"), ("data analyst", "Berlin", "DE")]
MAX_PAGES = 3

def collect(query, location, country, out):
    cursor = None
    for _ in range(MAX_PAGES):
        params = {"query": query, "location": location, "country": country,
                  "sort": "date", "limit": 20}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(API, headers=HEADERS, params=params, timeout=30)
        if r.status_code in (429, 503):
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        r.raise_for_status()
        data = r.json()["data"]
        out.write(json.dumps({
            "collected_at": datetime.datetime.utcnow().isoformat(),
            "query": query, "location": location, "jobs": data["jobs"],
        }) + "\n")
        if not data["pagination"]["has_more"]:
            break
        cursor = data["pagination"]["next_cursor"]

with open(f"postings-{datetime.date.today()}.jsonl", "a") as out:
    for q, loc, c in PAIRS:
        collect(q, loc, c, out)

A client timeout may hide a call that succeeded and was billed, as Retry Web API Calls Without Paying Twice explains. For the loop itself, Paginate a Web Data API in Python covers safe patterns. Rate limits and Retry-After are in the rate limits docs.

How do you turn the saved pages into an index?

Read every saved line, keep one row per id, count postings per week of date_published, then divide each week by a base week and multiply by 100. This sketch reads the files the collector wrote and assumes one fixed panel. It uses the earliest week as the base, so choose the base week before you look at the results.

import glob, json, datetime
from collections import Counter

seen = {}
for path in sorted(glob.glob("postings-*.jsonl")):
    for line in open(path):
        for job in json.loads(line)["jobs"]:
            seen.setdefault(job["id"], job)  # one row per posting id

weekly = Counter()
for job in seen.values():
    day = datetime.datetime.strptime(job["date_published"][:10], "%Y-%m-%d").date()
    year, week, _ = day.isocalendar()
    weekly[(year, week)] += 1

weeks = sorted(weekly)
base = weekly[weeks[0]]
for w in weeks:
    print(w, weekly[w], round(100 * weekly[w] / base, 1))

Each printed row is the ISO week, the unique postings and the index, where the base week is 100. Weeks near your first collection date are incomplete, because older postings that were already taken down never appear. Start the index a few weeks after the first run, and report the index with the panel and the base week stated.

What does the plan cost?

Cost is calls times 1 credit. Take 25 roles across 10 cities, which is 250 pairs. At up to 3 pages each, that's 250 × 3 = 750 calls a week, or 750 credits. Over four weeks that's 750 × 4 × 1 credit = 3,000 credits a month.

Panel Calls per run Weekly runs Credits a month (4 weeks) Within 1,000 free credits
250 pairs × 3 pages 750 1 3,000 No
250 pairs × 3 pages 750 2 6,000 No
250 pairs × 1 page 250 1 1,000 Yes

The last row fits inside the 1,000 free credits you get each month after verifying an email, with no card. The Starter pack holds 10,000 credits, so the one-run-a-week panel (3,000 credits a month) uses it up in just over three months, and purchased credits last 12 months. The Build pack holds 50,000 credits: at 6,000 credits a month, the two-run panel lasts 8 months. See pricing for what credits cost and Credits for how balances work.

When is another source a better fit?

If you need hires, wages or national benchmarks, use official statistics: they're built for that, and postings can't replace them. Postings fit when you need speed, fine geography, or specific titles and skills that official categories don't carry. Use both, and report the postings series as an index with a clear base period.

For other industries' fields, see Real Estate Listing Data: Fields That Matter for Valuation Search Trend Data for Demand Forecasting, and the Web data category. For pay data from another source, see Glassdoor API: Company Reviews and Salaries as JSON. To start collecting, get a free API key and run your first panel query in the playground.

APIs in this post

Frequently asked questions

How much does a job posting data collection cost?

Each successful call to /v1/indeed/jobs costs 1 credit and failed calls cost nothing. A weekly panel of 250 title and city pairs at 3 pages each is 750 calls, so 750 × 4 weeks × 1 credit = 3,000 credits a month. The monthly 1,000 free credits cover a smaller panel; pricing shows what credits cost beyond that.

Does the Indeed endpoint return salary?

The recorded response in our documentation has no salary field, and its attributes list holds employment type and skill tags only. Run your own queries and measure how many postings carry pay information before relying on it. Report that coverage rate next to any wage figure you publish.

Can I compare countries?

Yes, use the country parameter, up to 2 characters such as DE, and read location.country in the response. Keep the panel identical across countries and be careful with titles, because the same role is named differently by language. Normalise titles per country before comparing counts.

How do I avoid counting the same posting twice?

Deduplicate on the id field, which identifies a posting in the response. The same role can still be advertised more than once, so also compare company, normalised title and city within a window. Keep raw responses and dedupe at analysis time, so you can change the rule without recollecting.

What happens if a call fails or I run out of credits?

Errors return an HTTP status and a JSON body with code, message and request_id. Rate-limited (429) and unavailable (503) replies carry Retry-After. When the balance runs out you get insufficient_credits (402) and nothing is debited. See errors in the docs.

  1. Salary Data from Job Sites: Reading Ranges Without Bias

    Glassdoor's salaries endpoint returns p10 to p90 total pay, a currency and a national count. How to read those ranges and the biases of self-reported pay.

  2. Indeed Jobs API: Get Job Postings as JSON

    One GET to /v1/indeed/jobs returns job postings with company, location and apply URL as JSON for 1 credit. Parameters, a real response and monthly cost.

  3. Software Review Data Analysis for Competitive Research

    What Capterra, Trustpilot and Google Play reviews can and can't tell you about a competitor, which fields to keep, the sampling bias, and a worked rating tally.

Start with 1,000 free credits

No password to set. We email you a sign-in link, and your key works on every API.

No credit card. 1,000 free credits a month.