API DOCUMENTATION / CODE SAMPLES

Code samples

Request code in Python, Node.js, Ruby, PHP and curl for listing, counting, searching, syncing and exporting jobs.

Before you run anything

Every sample reads your key from the HEADCOUNTLABS_API_KEY environment variable and sends it as Authorization: Bearer .... Keep the key on a server or in a protected shell. Do not paste it into code you share or commit.

The base URL is https://api.headcountlabs.com. On any failure the samples print the status code and the error body, which has error.code, error.message and error.request_id. Quote the request ID when you contact support.

On October 14, 2026 the Python and Node.js samples on this page were run against the live API with an invalid key. Each one sent the request to the right host and path with the right header and body, and received the expected 401 invalid_api_key response. Run them with your own key to see real results. The Ruby, PHP and curl samples use the same requests.

Shared setup

Each language starts with a small helper. It sends the key, retries a 429 or 503 once or twice after the number of seconds in Retry-After (when that is two minutes or less), and raises an error with the response body for any other failure. The samples below call this helper.

import os
import time
import requests

BASE = "https://api.headcountlabs.com"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["HEADCOUNTLABS_API_KEY"]


def call(method, path, **kwargs):
    for attempt in range(3):
        response = session.request(method, BASE + path, timeout=60, **kwargs)
        wait = int(response.headers.get("Retry-After", "5"))
        if response.status_code in (429, 503) and attempt < 2 and wait <= 120:
            time.sleep(wait)
            continue
        if not response.ok:
            raise RuntimeError(f"{response.status_code} {response.text}")
        return response
    raise RuntimeError("unreachable")
// Node.js 18 or newer (built-in fetch)
const BASE = "https://api.headcountlabs.com";
const KEY = process.env.HEADCOUNTLABS_API_KEY;

async function call(method, path, body) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const response = await fetch(BASE + path, {
      method,
      headers: {
        Authorization: `Bearer ${KEY}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const wait = Number(response.headers.get("retry-after") ?? 5);
    if ([429, 503].includes(response.status) && attempt < 2 && wait <= 120) {
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
    return response;
  }
}
require "json"
require "net/http"
require "uri"

BASE = "https://api.headcountlabs.com"
KEY = ENV.fetch("HEADCOUNTLABS_API_KEY")

def call(method, path, body = nil)
  uri = URI(BASE + path)
  3.times do |attempt|
    request = Net::HTTP.const_get(method.capitalize).new(uri)
    request["Authorization"] = "Bearer #{KEY}"
    if body
      request["Content-Type"] = "application/json"
      request.body = JSON.generate(body)
    end
    response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) { |http| http.request(request) }
    wait = (response["Retry-After"] || 5).to_i
    if %w[429 503].include?(response.code) && attempt < 2 && wait <= 120
      sleep wait
      next
    end
    raise "#{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
    return response
  end
end
<?php
const BASE = "https://api.headcountlabs.com";

function call_api(string $method, string $path, ?array $body = null, $file = null): array {
    for ($attempt = 0; $attempt < 3; $attempt++) {
        $headers = ["Authorization: Bearer " . getenv("HEADCOUNTLABS_API_KEY")];
        $retryAfter = 5;
        $curl = curl_init(BASE . $path);
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        curl_setopt($curl, CURLOPT_TIMEOUT, 60);
        if ($body !== null) {
            $headers[] = "Content-Type: application/json";
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body));
        }
        curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($curl, CURLOPT_HEADERFUNCTION, function ($c, $line) use (&$retryAfter) {
            if (stripos($line, "retry-after:") === 0) $retryAfter = (int) trim(substr($line, 12));
            return strlen($line);
        });
        if ($file !== null) curl_setopt($curl, CURLOPT_FILE, $file);
        else curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        $text = curl_exec($curl);
        $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        curl_close($curl);
        if (in_array($status, [429, 503]) && $attempt < 2 && $retryAfter <= 120) { sleep($retryAfter); continue; }
        if ($status < 200 || $status >= 300) throw new RuntimeException("$status $text");
        return $file !== null ? [] : json_decode($text, true);
    }
}
# Put the key in an environment variable. Never paste it into a command you share.
export HEADCOUNTLABS_API_KEY="..."
export BASE="https://api.headcountlabs.com"
# --fail-with-body exits non-zero on a 4xx or 5xx and still prints the error body (curl 7.76 or newer)

List a page of jobs

Returns one page of jobs and a next_cursor. Filters go in the query string. The default page is 50 jobs; limit goes up to 250.

jobs = call("GET", "/v1/jobs?country=US&work_arrangement=remote&has_pay=true&posted_since=2026-10-01&limit=50").json()
print(len(jobs["data"]), jobs["next_cursor"])
const jobs = await (await call("GET", "/v1/jobs?country=US&work_arrangement=remote&has_pay=true&posted_since=2026-10-01&limit=50")).json();
console.log(jobs.data.length, jobs.next_cursor);
jobs = JSON.parse(call("get", "/v1/jobs?country=US&work_arrangement=remote&has_pay=true&posted_since=2026-10-01&limit=50").body)
puts jobs["data"].length, jobs["next_cursor"]
$jobs = call_api("GET", "/v1/jobs?country=US&work_arrangement=remote&has_pay=true&posted_since=2026-10-01&limit=50");
echo count($jobs["data"]), " ", $jobs["next_cursor"], "\n";
curl --fail-with-body -sS "$BASE/v1/jobs?country=US&work_arrangement=remote&has_pay=true&posted_since=2026-10-01&limit=50" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY"

Count matching jobs

Returns {count, snapshot_watermark} and no jobs. Use it to size a request before you make it.

count = call("GET", "/v1/jobs/count?country=US&work_arrangement=remote&has_pay=true").json()
print(count["count"], count["snapshot_watermark"])
const count = await (await call("GET", "/v1/jobs/count?country=US&work_arrangement=remote&has_pay=true")).json();
console.log(count.count, count.snapshot_watermark);
count = JSON.parse(call("get", "/v1/jobs/count?country=US&work_arrangement=remote&has_pay=true").body)
puts count["count"], count["snapshot_watermark"]
$count = call_api("GET", "/v1/jobs/count?country=US&work_arrangement=remote&has_pay=true");
echo $count["count"], " ", $count["snapshot_watermark"], "\n";
curl --fail-with-body -sS "$BASE/v1/jobs/count?country=US&work_arrangement=remote&has_pay=true" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY"

Read one job

Reads one combined job by ID. include=company adds the company object. Reading a single job never counts against your free allowance.

job = call("GET", "/v1/jobs/9f70ad16-ab51-49fe-b964-62067a7946b3?include=company").json()["data"]
print(job["title"]["value"], job["company"]["name"])
const job = (await (await call("GET", "/v1/jobs/9f70ad16-ab51-49fe-b964-62067a7946b3?include=company")).json()).data;
console.log(job.title.value, job.company.name);
job = JSON.parse(call("get", "/v1/jobs/9f70ad16-ab51-49fe-b964-62067a7946b3?include=company").body)["data"]
puts job["title"]["value"], job["company"]["name"]
$job = call_api("GET", "/v1/jobs/9f70ad16-ab51-49fe-b964-62067a7946b3?include=company")["data"];
echo $job["title"]["value"], " ", $job["company"]["name"], "\n";
curl --fail-with-body -sS "$BASE/v1/jobs/9f70ad16-ab51-49fe-b964-62067a7946b3?include=company" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY"

Search with a JSON body

Sends the filters as a JSON body, which suits long lists and Boolean text. The body can be up to 16,384 bytes. country and fields may be arrays. See the query language page for title_advanced.

body = {
    "title_advanced": "data AND (engineer OR scientist)",
    "country": [
        "US",
        "CA"
    ],
    "has_pay": True,
    "fields": [
        "job_id",
        "title",
        "employer_name"
    ],
    "limit": 25
}
result = call("POST", "/v1/jobs/search", json=body).json()
print(len(result["data"]), result["next_cursor"])
const body = {
  "title_advanced": "data AND (engineer OR scientist)",
  "country": [
    "US",
    "CA"
  ],
  "has_pay": true,
  "fields": [
    "job_id",
    "title",
    "employer_name"
  ],
  "limit": 25
};
const result = await (await call("POST", "/v1/jobs/search", body)).json();
console.log(result.data.length, result.next_cursor);
body = JSON.parse(<<~JSON)
{
  "title_advanced": "data AND (engineer OR scientist)",
  "country": [
    "US",
    "CA"
  ],
  "has_pay": true,
  "fields": [
    "job_id",
    "title",
    "employer_name"
  ],
  "limit": 25
}
JSON
result = JSON.parse(call("post", "/v1/jobs/search", body).body)
puts result["data"].length, result["next_cursor"]
$body = json_decode('{"title_advanced": "data AND (engineer OR scientist)", "country": ["US", "CA"], "has_pay": true, "fields": ["job_id", "title", "employer_name"], "limit": 25}', true);
$result = call_api("POST", "/v1/jobs/search", $body);
echo count($result["data"]), " ", $result["next_cursor"], "\n";
curl --fail-with-body -sS -X POST "$BASE/v1/jobs/search" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "title_advanced": "data AND (engineer OR scientist)",
  "country": [
    "US",
    "CA"
  ],
  "has_pay": true,
  "fields": [
    "job_id",
    "title",
    "employer_name"
  ],
  "limit": 25
}'

Search with an Elasticsearch-style query

Sends an Elasticsearch-style query. It is translated into the same filters. See the query language page for the clauses it accepts.

body = {
    "query": {
        "bool": {
            "filter": [
                {
                    "term": {
                        "country": "US"
                    }
                },
                {
                    "term": {
                        "work_arrangement": "remote"
                    }
                },
                {
                    "range": {
                        "pay_min": {
                            "gte": 150000
                        }
                    }
                },
                {
                    "exists": {
                        "field": "application_url"
                    }
                }
            ],
            "must": [
                {
                    "match": {
                        "title_advanced": "data AND (engineer OR scientist)"
                    }
                }
            ]
        }
    },
    "fields": [
        "job_id",
        "title",
        "employer_name"
    ],
    "limit": 25
}
result = call("POST", "/v1/jobs/search/es_dsl", json=body).json()
print(len(result["data"]), result["next_cursor"])
const body = {
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "country": "US"
          }
        },
        {
          "term": {
            "work_arrangement": "remote"
          }
        },
        {
          "range": {
            "pay_min": {
              "gte": 150000
            }
          }
        },
        {
          "exists": {
            "field": "application_url"
          }
        }
      ],
      "must": [
        {
          "match": {
            "title_advanced": "data AND (engineer OR scientist)"
          }
        }
      ]
    }
  },
  "fields": [
    "job_id",
    "title",
    "employer_name"
  ],
  "limit": 25
};
const result = await (await call("POST", "/v1/jobs/search/es_dsl", body)).json();
console.log(result.data.length, result.next_cursor);
body = JSON.parse(<<~JSON)
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "country": "US"
          }
        },
        {
          "term": {
            "work_arrangement": "remote"
          }
        },
        {
          "range": {
            "pay_min": {
              "gte": 150000
            }
          }
        },
        {
          "exists": {
            "field": "application_url"
          }
        }
      ],
      "must": [
        {
          "match": {
            "title_advanced": "data AND (engineer OR scientist)"
          }
        }
      ]
    }
  },
  "fields": [
    "job_id",
    "title",
    "employer_name"
  ],
  "limit": 25
}
JSON
result = JSON.parse(call("post", "/v1/jobs/search/es_dsl", body).body)
puts result["data"].length, result["next_cursor"]
$body = json_decode('{"query": {"bool": {"filter": [{"term": {"country": "US"}}, {"term": {"work_arrangement": "remote"}}, {"range": {"pay_min": {"gte": 150000}}}, {"exists": {"field": "application_url"}}], "must": [{"match": {"title_advanced": "data AND (engineer OR scientist)"}}]}}, "fields": ["job_id", "title", "employer_name"], "limit": 25}', true);
$result = call_api("POST", "/v1/jobs/search/es_dsl", $body);
echo count($result["data"]), " ", $result["next_cursor"], "\n";
curl --fail-with-body -sS -X POST "$BASE/v1/jobs/search/es_dsl" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "country": "US"
          }
        },
        {
          "term": {
            "work_arrangement": "remote"
          }
        },
        {
          "range": {
            "pay_min": {
              "gte": 150000
            }
          }
        },
        {
          "exists": {
            "field": "application_url"
          }
        }
      ],
      "must": [
        {
          "match": {
            "title_advanced": "data AND (engineer OR scientist)"
          }
        }
      ]
    }
  },
  "fields": [
    "job_id",
    "title",
    "employer_name"
  ],
  "limit": 25
}'

Walk every page of a list

Follows next_cursor until it is null. Keep the same filters and limit on every call, because a cursor is tied to them.

cursor = None
seen = 0
while True:
    path = "/v1/jobs?country=US&work_arrangement=remote&limit=250" + ("&cursor=" + cursor if cursor else "")
    page = call("GET", path).json()
    seen += len(page["data"])
    cursor = page["next_cursor"]
    if not cursor:
        break
print(seen, "jobs")
let cursor = null;
let seen = 0;
while (true) {
  const path = "/v1/jobs?country=US&work_arrangement=remote&limit=250" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : "");
  const page = await (await call("GET", path)).json();
  seen += page.data.length;
  cursor = page.next_cursor;
  if (!cursor) break;
}
console.log(seen, "jobs");
cursor = nil
seen = 0
loop do
  path = "/v1/jobs?country=US&work_arrangement=remote&limit=250" + (cursor ? "&cursor=#{URI.encode_www_form_component(cursor)}" : "")
  page = JSON.parse(call("get", path).body)
  seen += page["data"].length
  cursor = page["next_cursor"]
  break unless cursor
end
puts "#{seen} jobs"
$cursor = null;
$seen = 0;
do {
    $path = "/v1/jobs?country=US&work_arrangement=remote&limit=250" . ($cursor ? "&cursor=" . urlencode($cursor) : "");
    $page = call_api("GET", $path);
    $seen += count($page["data"]);
    $cursor = $page["next_cursor"];
} while ($cursor);
echo "$seen jobs\n";
cursor=""
while :; do
  page=$(curl --fail-with-body -sS -G "$BASE/v1/jobs" \
    -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY" \
    --data-urlencode "country=US" --data-urlencode "work_arrangement=remote" --data-urlencode "limit=250" \
    ${cursor:+--data-urlencode "cursor=$cursor"}) || break
  echo "$page" | jq ".data | length"
  cursor=$(echo "$page" | jq -r ".next_cursor // empty")
  [ -z "$cursor" ] && break
done

Read the changes feed

Reads created, updated and expired events since a time. Save resume_cursor and pass it back as after on the next call. Send either since or after, never both.

feed = call("GET", "/v1/jobs/changes?since=2026-10-13T00:00:00Z&types=created,updated,expired&limit=1000").json()
for event in feed["data"]:
    print(event["type"], event["job_id"], event["occurred_at"])
resume = feed["resume_cursor"]  # pass back as ?after=... next time
more = feed["has_more"]
const feed = await (await call("GET", "/v1/jobs/changes?since=2026-10-13T00:00:00Z&types=created,updated,expired&limit=1000")).json();
for (const event of feed.data) console.log(event.type, event.job_id, event.occurred_at);
const resume = feed.resume_cursor; // pass back as ?after=... next time
const more = feed.has_more;
feed = JSON.parse(call("get", "/v1/jobs/changes?since=2026-10-13T00:00:00Z&types=created,updated,expired&limit=1000").body)
feed["data"].each { |event| puts [event["type"], event["job_id"], event["occurred_at"]].join(" ") }
resume = feed["resume_cursor"] # pass back as ?after=... next time
more = feed["has_more"]
$feed = call_api("GET", "/v1/jobs/changes?since=2026-10-13T00:00:00Z&types=created,updated,expired&limit=1000");
foreach ($feed["data"] as $event) echo $event["type"], " ", $event["job_id"], " ", $event["occurred_at"], "\n";
$resume = $feed["resume_cursor"]; // pass back as ?after=... next time
$more = $feed["has_more"];
curl --fail-with-body -sS "$BASE/v1/jobs/changes?since=2026-10-13T00:00:00Z&types=created,updated,expired&limit=1000" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY"
# Next call: ...?after=<resume_cursor from the last response>

Create, poll and download an export

Creates an export of up to 100,000 jobs, waits for it to finish and saves each gzip file. Files hold one JSON job per line. The bulk download walkthrough explains each step.

job = call("POST", "/v1/jobs/exports", json={
    "country": "US",
    "work_arrangement": "remote",
    "has_pay": True,
    "fields": [
        "job_id",
        "title",
        "employer_name",
        "locations",
        "postings"
    ],
    "max_records": 50000
}).json()
while job["status"] in ("queued", "running"):
    time.sleep(10)
    job = call("GET", "/v1/jobs/exports/" + job["export_id"]).json()
if job["status"] != "completed":
    raise RuntimeError(job.get("error", job["status"]))
for f in job["files"]:
    data = call("GET", f["url"], stream=True)
    with open(f"jobs-{f['n']}.jsonl.gz", "wb") as out:
        for chunk in data.iter_content(1 << 20):
            out.write(chunk)
let job = await (await call("POST", "/v1/jobs/exports", {
  "country": "US",
  "work_arrangement": "remote",
  "has_pay": true,
  "fields": [
    "job_id",
    "title",
    "employer_name",
    "locations",
    "postings"
  ],
  "max_records": 50000
})).json();
while (["queued", "running"].includes(job.status)) {
  await new Promise((r) => setTimeout(r, 10000));
  job = await (await call("GET", `/v1/jobs/exports/${job.export_id}`)).json();
}
if (job.status !== "completed") throw new Error(job.error ?? job.status);
const { writeFile } = await import("node:fs/promises");
for (const f of job.files) {
  const file = await call("GET", f.url);
  await writeFile(`jobs-${f.n}.jsonl.gz`, Buffer.from(await file.arrayBuffer()));
}
job = JSON.parse(call("post", "/v1/jobs/exports", JSON.parse(<<~JSON)).body)
{
  "country": "US",
  "work_arrangement": "remote",
  "has_pay": true,
  "fields": [
    "job_id",
    "title",
    "employer_name",
    "locations",
    "postings"
  ],
  "max_records": 50000
}
JSON
while %w[queued running].include?(job["status"])
  sleep 10
  job = JSON.parse(call("get", "/v1/jobs/exports/#{job["export_id"]}").body)
end
raise (job["error"] || job["status"]) unless job["status"] == "completed"
job["files"].each do |f|
  File.binwrite("jobs-#{f["n"]}.jsonl.gz", call("get", f["url"]).body)
end
$job = call_api("POST", "/v1/jobs/exports", json_decode('{"country": "US", "work_arrangement": "remote", "has_pay": true, "fields": ["job_id", "title", "employer_name", "locations", "postings"], "max_records": 50000}', true));
while (in_array($job["status"], ["queued", "running"])) {
    sleep(10);
    $job = call_api("GET", "/v1/jobs/exports/" . $job["export_id"]);
}
if ($job["status"] !== "completed") throw new RuntimeException($job["error"] ?? $job["status"]);
foreach ($job["files"] as $f) {
    $out = fopen("jobs-" . $f["n"] . ".jsonl.gz", "wb");
    call_api("GET", $f["url"], null, $out);
    fclose($out);
}
# 1. Create the export (returns 202 with export_id)
curl --fail-with-body -sS -X POST "$BASE/v1/jobs/exports" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "country": "US",
  "work_arrangement": "remote",
  "has_pay": true,
  "fields": [
    "job_id",
    "title",
    "employer_name",
    "locations",
    "postings"
  ],
  "max_records": 50000
}'

# 2. Poll until status is completed
curl --fail-with-body -sS "$BASE/v1/jobs/exports/$EXPORT_ID" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY"

# 3. Download each file listed in files[].url (gzip, one JSON job per line)
curl --fail-with-body -sS -o jobs-1.jsonl.gz "$BASE/v1/jobs/exports/$EXPORT_ID/files/1" \
  -H "Authorization: Bearer $HEADCOUNTLABS_API_KEY"

What comes back

  • List and search responses have data, next_cursor and snapshot_watermark. Use the field dictionary to read each job.
  • A count response has count and snapshot_watermark.
  • 429 means you used your allowance or sent requests too fast. Wait for Retry-After. See rate limits.
  • 504 query_timeout means the query took longer than 10 seconds. Add a country, state, source or date range.
  • The errors page lists every code.

Continue in the reference

Choose the right route before you copy a sample.

Which route should I use? Get started
All API documentation