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.
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 https://api.joblistingsapi.com/v1/jobs?limit=10 \
-H "X-API-Key: $JLA_API_KEY"// 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 — 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.
| Parameter | Type | Plan | Notes |
|---|---|---|---|
limit | int | All plans | Results per page. Max 100 (200 on Scale). |
offset | int | All plans | Offset pagination; returns total. |
cursor | string | Growth+ | Stable cursor pagination; pass back next_cursor. |
posted_after | date-time | All plans | Only jobs listed at/after this instant. |
posted_before | date-time | All plans | Only jobs listed before this instant. |
updated_since | date-time | All plans | Records changed since — for delta sync. |
title | string | All plans | Free-text, case-insensitive substring match on the job title. The escape hatch for titles the role_category taxonomy doesn't cover. |
company | string | All plans | Company name match. |
location | string | All plans | Free-text substring match on the raw location string. Use country for structured country filtering. |
country | string | All plans | ISO 3166-1 alpha-2 country code, e.g. GB, US. Structured country filter — see /docs/taxonomy for all supported values. |
remote_only | boolean | All plans | Restrict to remote postings. |
source | string | All plans | Filter to one ATS, e.g. greenhouse. |
role_category | string | Growth+ | Normalized role taxonomy — accepts a category name or slug, e.g. software-engineering. Browse all values at /docs/taxonomy. |
salary_min | int | Growth+ | Lower salary bound (postings that disclose it). |
salary_max | int | Growth+ | Upper salary bound. |
currency | string | Growth+ | ISO 4217 currency for salary filters. |
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:
# 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.
| Field | Type | Notes |
|---|---|---|
id | int | Stable numeric identifier. |
title | string | Always populated. |
company | string | Always populated. |
locationEvery plan | object | raw, city, region, country_code (ISO alpha-2). |
employment_type | string? | e.g. FULL_TIME, when the source declares it. |
remote_policy | string? | remote / hybrid / onsite — meaningful on ~70%. |
is_remote | boolean | Convenience boolean for remote roles. |
remote_scope | string? | Geographic scope, e.g. "United Kingdom". |
role_categoryEvery plan | string? | Normalized role taxonomy. |
role_subcategoryEvery plan | string? | Finer-grained role taxonomy. |
salary | object? | 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_descriptionScale | object? | Parsed sections (responsibilities, requirements…). |
duplicate_cluster_idEvery plan | string? | Same posting seen on multiple boards shares this UUID. |
listed_at | date-time? | When the employer first listed the role. |
created_at | date-time | When we first ingested the record. |
updated_at | date-time | Last change — drives updated_since. |
valid_through | date-time? | Expiry hint for the posting. |
status | enum | "active" or "removed" once delisted. |
url | string | Original posting URL on the source ATS. Always populated. |
source | string | The 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
**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.
| Plan | Per minute | Per day | Per month |
|---|---|---|---|
| Free | 10 | 100 | 500 |
| Starter | 60 | 10,000 | 200,000 |
| Growth | 300 | 100,000 | 2,000,000 |
| Scale | 600 | 500,000 | 10,000,000 |
Errors
Errors use standard HTTP status codes with a JSON body describing what went wrong.
| Status | Meaning | Fix |
|---|---|---|
401 | Missing or malformed API key. | Send a valid key in the X-API-Key header. |
403 | Key valid, but this plan can't use that filter or field. | Upgrade, or drop the gated parameter. |
404 | No job with that id (or it has been purged). | Check the id; removed jobs may still 200 with status "removed". |
422 | A parameter failed validation. | Read the detail array; fix the named parameter. |
429 | Rate 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.
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.
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>.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 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.
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.