# Pinpoint Jobs API: Get Pinpoint Job Listings as JSON

> Pinpoint's postings.json returns a company's jobs with descriptions and salary in one keyless call. The fields, the date workaround and the ID gotcha.

Source: https://cleanjobdata.com/articles/how-to-scrape-pinpoint-job-listings
Published: 2026-11-01

TL;DR: Every Pinpoint career site serves https://{company}.pinpointhq.com/postings.json, a public JSON list of all open postings with descriptions, workplace type and structured location, in one request with no API key. Salary is in compensation_minimum and compensation_maximum, but only when compensation_visible is true. The JSON has no posting date, so the date comes from the same site's jobs.rss feed, matched by requisition ID.

To get a company's jobs from Pinpoint, call `https://{company}.pinpointhq.com/postings.json`: it returns every open posting, with its description, in one JSON response and needs no API key. The one thing it lacks is a posting date, which you get from the same site's `jobs.rss` feed.

That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain a Pinpoint scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the [Jobs API docs](https://cleanjobdata.com/docs/api/jobs).

## At a Glance

| | |
|---|---|
| Endpoint | `GET https://{company}.pinpointhq.com/postings.json` |
| API key | None |
| Pagination | None: one response holds every posting |
| Full description | Yes, in the list, split into sections |
| Salary field | `compensation_minimum` / `compensation_maximum`, when `compensation_visible` is true |
| Remote | `workplace_type` (`remote`, `hybrid` or on-site) |

## Finding the Company Subdomain

Pinpoint career sites live at `https://{company}.pinpointhq.com`, so for `acme.pinpointhq.com` the subdomain is `acme`. Job pages look like `https://acme.pinpointhq.com/en/postings/{uuid}`. If a company shows its Pinpoint jobs on its own site, look for links or requests to `pinpointhq.com` in the page source or Network tab.

## The List Request

```bash
curl "https://acme.pinpointhq.com/postings.json"
```

The response is an object with a `data` array (one posting shown, trimmed):

```json
{
  "data": [
    {
      "id": "524420",
      "title": "QA Engineer",
      "url": "https://acme.pinpointhq.com/en/postings/39a39239-f6ec-4a8d-8013-f5d760fde0a1",
      "path": "/en/postings/39a39239-f6ec-4a8d-8013-f5d760fde0a1",
      "description": "<div><strong>About us:</strong></div>...",
      "key_responsibilities": "<ul><li>...</li></ul>",
      "key_responsibilities_header": "Key Responsibilities",
      "skills_knowledge_expertise": "",
      "skills_knowledge_expertise_header": " ",
      "benefits": null,
      "benefits_header": "Job Benefits",
      "employment_type": "full_time",
      "employment_type_text": "Full Time",
      "workplace_type": "hybrid",
      "workplace_type_text": "Hybrid",
      "compensation_visible": false,
      "compensation_minimum": null,
      "compensation_maximum": null,
      "compensation_currency": null,
      "compensation_frequency": null,
      "deadline_at": null,
      "reporting_to": "",
      "job": {
        "id": "529976",
        "requisition_id": "0048",
        "department": { "id": "77352", "name": "Engineering" }
      },
      "location": {
        "id": "86155",
        "city": "Bengaluru",
        "name": "Bangalore - India",
        "postal_code": "560102",
        "province": "Karnataka",
        "street_address": "HSR Layout"
      }
    }
  ]
}
```

## Response Fields

| Field | What it holds |
|---|---|
| `id` | Numeric posting ID (see the ID gotcha below) |
| `url`, `path` | Public posting page; the URL contains a stable UUID |
| `title` | Job title |
| `description` | Main HTML description |
| `key_responsibilities`, `skills_knowledge_expertise`, `benefits` | Extra HTML sections, each with a `*_header` title; often empty or `null` |
| `employment_type`, `employment_type_text` | Code and label, e.g. `full_time` / "Full Time" |
| `workplace_type`, `workplace_type_text` | Code and label, e.g. `hybrid` / "Hybrid" |
| `compensation_*` | Pay range, currency and frequency, gated by `compensation_visible` |
| `job.id` | Requisition ID, shared by every posting for the same role |
| `job.department.name` | Department label |
| `location` | `city`, `province`, `postal_code`, `street_address` and a display `name` |
| `deadline_at` | Closing date, if set |

## Getting the Posting Date

`postings.json` has no date field. The same site publishes an RSS feed that does:

```bash
curl "https://acme.pinpointhq.com/jobs.rss"
```

Each `<item>` has a `<pubDate>` and a `<link>` that contains `/jobs/{id}`. That number is the requisition ID, `job.id` in the JSON, not the posting ID. Build a map from requisition ID to `pubDate` and look each posting up through `posting.job.id`. Postings that share a requisition share its date.

## Fetching Jobs (JavaScript)

```js
const SECTIONS = [
  ["description", null],
  ["key_responsibilities", "key_responsibilities_header"],
  ["skills_knowledge_expertise", "skills_knowledge_expertise_header"],
  ["benefits", "benefits_header"],
];

function datesByRequisition(rssXml) {
  const map = new Map();
  for (const item of rssXml.match(/<item>[\s\S]*?<\/item>/g) ?? []) {
    const link = item.match(/<link>([\s\S]*?)<\/link>/)?.[1] ?? "";
    const id = link.match(/\/jobs\/(\d+)/)?.[1];
    const pubDate = item.match(/<pubDate>([\s\S]*?)<\/pubDate>/)?.[1];
    if (id && pubDate) map.set(id, new Date(pubDate.trim()));
  }
  return map;
}

export async function fetchPinpointJobs(company) {
  const base = `https://${company}.pinpointhq.com`;
  const [postingsRes, rssRes] = await Promise.all([
    fetch(`${base}/postings.json`),
    fetch(`${base}/jobs.rss`),
  ]);
  if (!postingsRes.ok) throw new Error(`Pinpoint ${postingsRes.status}`);
  const { data = [] } = await postingsRes.json();
  const dates = rssRes.ok ? datesByRequisition(await rssRes.text()) : new Map();

  return data.map((p) => {
    const uuid = p.url?.match(/\/postings\/([0-9a-f-]{36})/i)?.[1];
    const html = SECTIONS
      .filter(([key]) => p[key])
      .map(([key, header]) => (header && p[header]?.trim() ? `<h3>${p[header]}</h3>` : "") + p[key])
      .join("\n");
    const showPay = p.compensation_visible === true;
    return {
      id: uuid ?? String(p.id),
      title: p.title,
      url: p.url,
      published: dates.get(String(p.job?.id)) ?? null,
      location: p.location?.city
        ? [p.location.city, p.location.province].filter(Boolean).join(", ")
        : p.location?.name ?? "",
      workplaceType: p.workplace_type,
      employmentType: p.employment_type_text,
      department: p.job?.department?.name,
      salaryMin: showPay ? p.compensation_minimum : null,
      salaryMax: showPay ? p.compensation_maximum : null,
      salaryCurrency: showPay ? p.compensation_currency : null,
      salaryFrequency: showPay ? p.compensation_frequency : null,
      html,
    };
  });
}
```

The same in Python is two `requests.get` calls, a regex over the RSS and a loop over `data`.

## Gotchas

**Use the URL's UUID as the job ID.** Pinpoint has reassigned numeric posting IDs to different postings in the past, after a platform migration. The UUID in `url` (`/postings/{uuid}`) stays with the posting, and old numeric job URLs redirect to it. Key your records on the UUID and fall back to `id` only when a URL has no UUID.

**A requisition can have several postings.** One `job.id` can carry multiple postings, for example the same role in different locations or re-advertised under a new title. Each posting is what candidates see, so treat postings, not requisitions, as your jobs.

**Only trust pay when `compensation_visible` is true.** When it's false, the four compensation fields are `null`, even if the employer entered figures internally. When it's true, check `compensation_frequency` before comparing figures, since the unit isn't always annual. See the [salary extraction guide](https://cleanjobdata.com/guides/salary-data-extraction).

**Location `city` can be empty.** Some postings leave `city` blank while `location.name` still holds a readable place such as `"Bangalore - India"`. Fall back to `name` and parse it, and note that its city name can differ from `city` on the same posting (`Bangalore` vs `Bengaluru`). The [location normalization guide](https://cleanjobdata.com/guides/job-location-normalization) covers this.

**Section headers can be blank.** A `*_header` can be a single space, and a section can be an empty string rather than missing. Only render sections that have content.

**Old requisitions stay open.** Evergreen roles can carry an RSS date that's years old. Check the date before you treat a posting as fresh.

## Public Endpoint vs Pinpoint's Official API

Pinpoint has an official API for its customers that needs an API key from the employer's own Pinpoint account and covers that company's recruiting data. For published postings from companies you don't work with, the public `postings.json` and `jobs.rss` on each career site are what you use.

## When This Is Enough

For one company, Pinpoint is one of the easiest platforms there is: two requests, every posting, descriptions and pay included.

## When It Isn't

Across many companies and platforms you end up maintaining a different collector for each, with different ID rules, date sources and salary formats. The [ATS public job postings API list](https://cleanjobdata.com/articles/ats-public-job-postings-api-list) shows how they compare.

CleanJobData reads Pinpoint career sites alongside other platforms and returns one schema, with locations resolved to cities, states and countries, a remote type, an employment type, and salary converted to a consistent range when the employer shows it. Try it in the [Playground](https://cleanjobdata.com/playground) or see the [API Documentation](https://cleanjobdata.com/docs).

## Skip the scraper: get this data from the CleanJobData API

If you'd rather not maintain a Pinpoint collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in `pagination.next_page`.

```bash
curl "https://api.cleanjobdata.com/jobs?title=engineer&domain=acme.pinpointhq.com&limit=20" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"
```

The `domain` filter is an exact match on the host of each job's `application_url`. Pinpoint jobs apply on each company's own `pinpointhq.com` subdomain, so this scopes the query to one employer; use `company_website_url` or `employer_id` as alternatives. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.

- [Jobs API reference](https://cleanjobdata.com/docs/api/jobs) for every filter and response field
- [Playground](https://cleanjobdata.com/playground) to run queries without writing code
- [Pricing](https://cleanjobdata.com/pricing) — the Trial is free with no card required

## FAQ

### Does Pinpoint have a public jobs API?

Yes. Every Pinpoint career site serves `https://{company}.pinpointhq.com/postings.json`, which returns all open postings with descriptions in one request and needs no API key.

### Why doesn't Pinpoint's postings.json have a date?

The JSON simply has no posting date field. The same career site's `jobs.rss` feed has a `pubDate` per requisition, which you can match to each posting through `job.id`.

### Does Pinpoint include salary?

Yes, when the employer chooses to show it. `compensation_minimum`, `compensation_maximum`, `compensation_currency` and `compensation_frequency` are filled in only when `compensation_visible` is true, and they're `null` otherwise.

### What should I use as a unique ID for Pinpoint jobs?

Use the UUID in the posting's `url`, from the `/postings/{uuid}` path. Numeric posting IDs have been reassigned to different postings in the past, while the UUID stays with the same posting.
