---
title: "Job Posting Data for Labour-Market Research | Perceptlayer"
description: "Which Indeed posting fields support trend analysis, where postings mislead versus hires, and a weekly collection plan that costs 3,000 credits a month."
url: https://perceptlayer.com/blog/job-posting-data-labour-market-research
---

[Blog](https://perceptlayer.com/blog) / [Web data by industry](https://perceptlayer.com/blog/category/web-data)

# Job Posting Data for Labour-Market Research

21 Oct 2026 Perceptlayer 8 min read

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.

## Key takeaways

-   Trend analysis needs `title`, `location` and `date_published` from each posting, plus `id` to remove duplicates between runs.
-   The recorded response has no salary field. Check your own queries before building a pay series, and report the share of postings that have one.
-   Postings are not hires: official data such as JOLTS counts both, and the two series differ in level and timing.
-   Run the same query list on the same schedule. A fixed panel of 750 calls a week costs 750 calls × 4 weeks × 1 credit = 3,000 credits a month. See [pricing](https://perceptlayer.com/pricing) for what credits cost.
-   Store raw responses with a collection timestamp. You can't rebuild last month's view of the market later.

## 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.

```bash
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:

```json
{
  "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](https://perceptlayer.com/blog/indeed-jobs-api-json). The [Indeed API: Jobs](https://perceptlayer.com/apis/indeed/jobs) page lists the endpoint, and the playground on the [Indeed API](https://perceptlayer.com/apis/indeed) 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](https://www.bls.gov/jlt/), 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](https://www.bls.gov/opub/hom/jlt/home.htm), 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](https://ec.europa.eu/eurostat/statistics-explained/index.php?title=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.

```python
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](https://perceptlayer.com/blog/retry-web-api-calls-without-paying-twice) explains. For the loop itself, [Paginate a Web Data API in Python](https://perceptlayer.com/blog/paginate-web-data-api-python) covers safe patterns. Rate limits and `Retry-After` are in the [rate limits docs](https://docs.perceptlayer.com/rate-limits).

### 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.

```python
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](https://perceptlayer.com/pricing) for what credits cost and [Credits](https://docs.perceptlayer.com/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](https://perceptlayer.com/blog/real-estate-listing-data-valuation-fields) [Search Trend Data for Demand Forecasting](https://perceptlayer.com/blog/search-trend-data-demand-forecasting), and the [Web data category](https://perceptlayer.com/blog/category/web-data). For pay data from another source, see [Glassdoor API: Company Reviews and Salaries as JSON](https://perceptlayer.com/blog/glassdoor-salary-data-api). To start collecting, [get a free API key](https://perceptlayer.com/signup) and run your first panel query in the playground.

## APIs in this post

-   [Indeed **1 credit per call** · 1 endpoint](https://perceptlayer.com/apis/indeed)

## 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](https://perceptlayer.com/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](https://docs.perceptlayer.com/errors).

jobs labour market indeed job postings trend analysis

## More from the blog

1.  [Web data by industry](https://perceptlayer.com/blog/category/web-data) 6 Oct 2026 Perceptlayer 7 min read
    
    ## [Salary Data from Job Sites: Reading Ranges Without Bias](https://perceptlayer.com/blog/salary-data-job-sites-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.  [API guides](https://perceptlayer.com/blog/category/api-guides) 3 Aug 2026 Updated 26 Oct 2026 Perceptlayer 8 min read
    
    ## [Indeed Jobs API: Get Job Postings as JSON](https://perceptlayer.com/blog/indeed-jobs-api-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.  [Web data by industry](https://perceptlayer.com/blog/category/web-data) 30 Sept 2026 Perceptlayer 7 min read
    
    ## [Software Review Data Analysis for Competitive Research](https://perceptlayer.com/blog/software-review-data-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.
