Documentation

API reference

A single REST endpoint family over normalized, deduplicated job postings. Base URL https://api.joblistingsapi.com/v1.

Inspect a real sample (no key needed)

A frozen snapshot of ~100 real job records is available at /sample.json — the exact shape the API returns, no authentication required.

Authentication

Every request is authenticated with your API key in the X-API-Key header. Keys are issued from your dashboard; keep them server-side and never ship them in client code.

bash
curl https://api.joblistingsapi.com/v1/jobs \
  -H "X-API-Key: jla_live_your_key_here"

Quickstart

Set JLA_API_KEY in your environment, then fetch your first ten jobs. Each snippet runs as written.

curl
curl https://api.joblistingsapi.com/v1/jobs?limit=10 \
  -H "X-API-Key: $JLA_API_KEY"
Node 20+
// Node 20+ (global fetch). Default-tier paging uses offset + total.
const KEY = process.env.JLA_API_KEY;
const base = "https://api.joblistingsapi.com/v1";
let offset = 0;
const out = [];
while (true) {
  const r = await fetch(`${base}/jobs?limit=50&offset=${offset}`, { headers: { "X-API-Key": KEY } });
  const { jobs, total } = await r.json();
  out.push(...jobs);
  offset += jobs.length;
  if (offset >= total || jobs.length === 0) break;
}
console.log(`fetched ${out.length} of ${total}`);
// Growth+ tiers: use cursor pagination (next_cursor) — see the delta-sync recipe.
Python 3 · requests
# Python 3 — pip install requests
import os, requests

res = requests.get(
    "https://api.joblistingsapi.com/v1/jobs",
    params={"limit": 10},
    headers={"X-API-Key": os.environ["JLA_API_KEY"]},
)
res.raise_for_status()
data = res.json()
print(len(data["jobs"]), "jobs")

Listing jobs & filters

GET /jobs returns a page of JobV1 records. Combine any of the filters below; gated filters return 403 on plans that don't include them.

ParameterTypePlanNotes
limitintAll plansResults per page. Max 100 (200 on Scale).
offsetintAll plansOffset pagination; returns total.
cursorstringGrowth+Stable cursor pagination; pass back next_cursor.
posted_afterdate-timeAll plansOnly jobs listed at/after this instant.
posted_beforedate-timeAll plansOnly jobs listed before this instant.
updated_sincedate-timeAll plansRecords changed since — for delta sync.
titlestringAll plansFree-text, case-insensitive substring match on the job title. The escape hatch for titles the role_category taxonomy doesn't cover.
companystringAll plansCompany name match.
locationstringAll plansFree-text substring match on the raw location string. Use country for structured country filtering.
countrystringAll plansISO 3166-1 alpha-2 country code, e.g. GB, US. Structured country filter — see /docs/taxonomy for all supported values.
remote_onlybooleanAll plansRestrict to remote postings.
sourcestringAll plansFilter to one ATS, e.g. greenhouse.
role_categorystringGrowth+Normalized role taxonomy — accepts a category name or slug, e.g. software-engineering. Browse all values at /docs/taxonomy.
salary_minintGrowth+Lower salary bound (postings that disclose it).
salary_maxintGrowth+Upper salary bound.
currencystringGrowth+ISO 4217 currency for salary filters.
Browse the full role + location taxonomyAll ?role_category= and ?country= values — /docs/taxonomy

Pagination & delta sync

Two pagination modes. offset + total works on every plan and is simplest for shallow, one-off queries. Cursor pagination (Growth+) is stable by id — it won't skip or repeat records when the dataset shifts under you, which makes it the right choice for keeping a mirror.

For incremental sync, pass updated_since with your last successful sync timestamp. The recommended pattern for a local mirror is cursor + updated_since together:

delta sync
# 1. Initial backfill — walk the cursor to the end
GET /v1/jobs?limit=100&updated_since=2026-06-01T00:00:00Z
  → { "jobs": [...], "next_cursor": "eyJpZCI6..." }
GET /v1/jobs?limit=100&cursor=eyJpZCI6...   # repeat until next_cursor is null

# 2. Incremental — only what changed since your last sync
GET /v1/jobs?limit=100&updated_since=2026-06-11T06:00:00Z&cursor=...

Response fields

Every record is a JobV1. Taxonomy fields ship on every plan; description_html requires Starter+, and structured_description is Scale-only.

FieldTypeNotes
idintStable numeric identifier.
titlestringAlways populated.
companystringAlways populated.
locationEvery planobjectraw, city, region, country_code (ISO alpha-2).
employment_typestring?e.g. FULL_TIME, when the source declares it.
remote_policystring?remote / hybrid / onsite — meaningful on ~70%.
is_remotebooleanConvenience boolean for remote roles.
remote_scopestring?Geographic scope, e.g. "United Kingdom".
role_categoryEvery planstring?Normalized role taxonomy.
role_subcategoryEvery planstring?Finer-grained role taxonomy.
salaryobject?min, max, currency, period, display. Only ~15–25% disclose it.
description_htmlStarter+string?HTML body as published by the employer — sanitize before rendering in a browser. Present on ~90% of postings.
structured_descriptionScaleobject?Parsed sections (responsibilities, requirements…).
duplicate_cluster_idEvery planstring?Same posting seen on multiple boards shares this UUID.
listed_atdate-time?When the employer first listed the role.
created_atdate-timeWhen we first ingested the record.
updated_atdate-timeLast change — drives updated_since.
valid_throughdate-time?Expiry hint for the posting.
statusenum"active" or "removed" once delisted.
urlstringOriginal posting URL on the source ATS. Always populated.
sourcestringThe ATS platform. Always populated.

Response shape & errors

List responses wrap records in a jobs array with a total count (offset mode) or next_cursor (cursor mode, Growth+). Single-job responses wrap in job. All responses include a success boolean.

Error responses always return { "detail": "...", "code": "..." }. Branch on the stable code field — never parse the English detail string, which may change. On 422 (validation), detail is an array not a string. Stable codes: missing_api_key, invalid_api_key, account_suspended, plan_filter_forbidden, unknown_role_category, rate_limited, not_found, validation_error.

response shape
## Response shape

**List response** (GET /jobs):
```json
{ "success": true, "jobs": [...], "total": 12345 }
```
With cursor pagination (Growth+), `next_cursor` replaces `total`:
```json
{ "success": true, "jobs": [...], "next_cursor": "eyJpZCI6..." }
```
When `next_cursor` is `null`, you have reached the end.

**Single-job response** (GET /jobs/{id}):
```json
{ "success": true, "job": { ... } }
```

**Error response**: errors always return `{ "detail": "...", "code": "..." }`.
`detail` is a string on most errors; on 422 (validation) it is an array.
Branch on the stable `code` field — never parse the English `detail` string.

Stable error codes: `missing_api_key`, `invalid_api_key`, `account_suspended`,
`plan_filter_forbidden`, `unknown_role_category`, `rate_limited`, `not_found`,
`validation_error`.

Rate limits

Limits are enforced per minute, per day, and per month. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Exceed a window and you get a 429 with a Retry-After header.

PlanPer minutePer dayPer month
Free10100500
Starter6010,000200,000
Growth300100,0002,000,000
Scale600500,00010,000,000

Errors

Errors use standard HTTP status codes with a JSON body describing what went wrong.

StatusMeaningFix
401Missing or malformed API key.Send a valid key in the X-API-Key header.
403Key valid, but this plan can't use that filter or field.Upgrade, or drop the gated parameter.
404No job with that id (or it has been purged).Check the id; removed jobs may still 200 with status "removed".
422A parameter failed validation.Read the detail array; fix the named parameter.
429Rate limit exceeded for the current window.Back off until the Retry-After header's seconds elapse.

Taxonomy reference

The full list of valid ?role_category= slugs and ?country= codes — 17 role categories and 204 countries with active postings — lives on a dedicated reference page.

Open the taxonomy reference/docs/taxonomy

Interactive reference

Prefer to poke at it live? The full OpenAPI reference, with a try-it console, is generated from the same schema.

Open the interactive referencehttps://api.joblistingsapi.com/v1/docs

Use from an AI agent

Copy any prompt below into your AI assistant to get instant help. Each prompt is self-contained — it points the agent at the right docs so it can answer questions without guessing.

Using Cursor or Claude Code? Register https://joblistingsapi.com/llms.txt as an @Docs source so your editor can index the full API reference automatically.

Integrate the API
You are integrating the Job Listings API. Read https://joblistingsapi.com/llms-full.txt and the OpenAPI spec at https://api.joblistingsapi.com/v1/openapi.json. Base URL is https://api.joblistingsapi.com/v1; auth via the X-API-Key header (key format jla_live_*; get one free, no card, at https://joblistingsapi.com/login). Always resolve filter values from https://api.joblistingsapi.com/v1/taxonomy/roles and https://api.joblistingsapi.com/v1/taxonomy/locations rather than guessing slugs. role_category, salary_* and cursor pagination are Growth+ only. Errors return {detail, code} — branch on code. Help me <task>.
Get an API key
Walk me through getting a Job Listings API key: open https://joblistingsapi.com/login, request a magic link, verify it, and issue a jla_live_ key from the dashboard. The Free tier needs no card.
Build a delta sync
Build a backfill-then-delta sync against https://api.joblistingsapi.com/v1/jobs: page with cursor until next_cursor is null, then poll updated_since on a schedule. Note: cursor pagination requires Growth+; on Free/Starter page with limit+total instead.
Robust error handling
Generate error handling for the Job Listings API: 401 missing_api_key/invalid_api_key, 403 plan_filter_forbidden/account_suspended, 404 not_found, 422 validation_error or unknown_role_category (detail is an array on validation), 429 rate_limited (honor Retry-After + X-RateLimit-*). Branch on the response "code" field, not the English detail.