All platforms

Pinpoint Jobs API.

Pull every open role — full HTML descriptions, pay ranges, department data, and remote/hybrid flags — from any Pinpoint careers site in a single unauthenticated JSON request.

Get API access
Pinpoint
Live
25K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
Companies using Pinpoint
Tripledot StudiosSKIMSSafetyWingCard Factory
Developer tools

Try the API.

Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.

What's in every response.

Data fields, real-world applications, and the companies already running on Pinpoint.

Data fields
  • Full HTML Job Descriptions
  • Compensation Ranges & Currency
  • Department & Division
  • Employment & Workplace Type
  • Location & Postal Code
  • Application Deadlines
Use cases
  1. 01Compensation Benchmarking
  2. 02Remote & Hybrid Job Feeds
  3. 03Careers Page Aggregation
  4. 04Department-Level Hiring Signals
Trusted by
Tripledot StudiosSKIMSSafetyWingCard Factory
DIY GUIDE

How to scrape Pinpoint.

Step-by-step guide to extracting jobs from Pinpoint-powered career pages—endpoints, authentication, and working code.

RESTbeginnerNo hard limit observed; keep ~5 req/sNo auth

Fetch all job listings from the API

Every Pinpoint tenant exposes a public postings.json endpoint that returns all active jobs with full descriptions in one call. No authentication is required; the XHR headers mirror how the careers site itself requests the data.

Step 1: Fetch all job listings from the API
import requests

company_slug = "cardfactory"  # from https://{slug}.pinpointhq.com
url = f"https://{company_slug}.pinpointhq.com/postings.json"

headers = {
    "Accept": "application/json, text/javascript, */*; q=0.01",
    "X-Requested-With": "XMLHttpRequest",
}

response = requests.get(url, headers=headers, timeout=15)
response.raise_for_status()

jobs = response.json().get("data", [])
print(f"Found {len(jobs)} active jobs")

Parse job details from the response

Read the fields you need from each posting. Note the shapes: department and division live under a nested job object, location is a top-level object with plain-string fields, and compensation is a set of flat compensation_* keys.

Step 2: Parse job details from the response
def build_location(job: dict) -> str | None:
    loc = job.get("location") or {}
    # Pinpoint prefers a single display name; fall back to city + province.
    if loc.get("name"):
        return loc["name"].strip()
    parts = [p for p in (loc.get("city"), loc.get("province")) if p]
    return ", ".join(parts) or None

def build_salary(job: dict) -> str | None:
    # Compensation fields are flat and only meaningful when visible.
    if not job.get("compensation_visible"):
        return None
    low = job.get("compensation_minimum")
    high = job.get("compensation_maximum")
    if low is None and high is None:
        return None
    currency = job.get("compensation_currency") or ""
    frequency = job.get("compensation_frequency") or ""
    return f"{currency} {low}-{high} {frequency}".strip()

for job in jobs:
    detail = job.get("job") or {}
    print({
        "id": job.get("id"),
        "title": job.get("title"),
        "department": (detail.get("department") or {}).get("name"),
        "division": (detail.get("division") or {}).get("name"),
        "location": build_location(job),
        "workplace_type": job.get("workplace_type_text"),
        "employment_type": job.get("employment_type_text"),
        "url": job.get("url"),
        "salary": build_salary(job),
        "deadline": job.get("deadline_at"),
    })

Assemble the complete job description

Pinpoint splits the posting body across several HTML fields, each with an optional section header. Concatenate them the way the board renders them to reconstruct the full description.

Step 3: Assemble the complete job description
def build_full_description(job: dict) -> str:
    """Concatenate the HTML sections the way Pinpoint's board renders them."""
    parts: list[str] = []

    def add(body_key: str, header_key: str) -> None:
        body = job.get(body_key)
        if not body:
            return
        header = job.get(header_key)
        if header:
            parts.append(f"<h3>{header.strip()}</h3>")
        parts.append(body)

    if job.get("description"):
        parts.append(job["description"])
    add("key_responsibilities", "key_responsibilities_header")
    add("skills_knowledge_expertise", "skills_knowledge_expertise_header")
    add("benefits", "benefits_header")

    return "\n<br>\n".join(parts)

for job in jobs:
    html = build_full_description(job)
    print(f"{job.get('title')}: {len(html)} chars")

Resolve the tenant slug and handle errors

Turn any Pinpoint URL into the tenant slug that postings.json expects. Company boards live at {slug}.pinpointhq.com, while the shared apply host uses apply.pinpointhq.com/en/companies/{slug}; skip app.pinpointhq.com, which is Pinpoint's product login, not a customer board.

Step 4: Resolve the tenant slug and handle errors
from urllib.parse import urlparse
import requests
from requests.exceptions import RequestException

def resolve_company_slug(board_url: str) -> str | None:
    parsed = urlparse(board_url)
    host = (parsed.hostname or "").lower()
    if "pinpointhq.com" not in host:
        return None
    # Shared apply host: apply.pinpointhq.com/en/companies/{slug}
    if host.startswith(("apply.", "www.")):
        parts = parsed.path.strip("/").split("/")
        if len(parts) >= 3 and parts[0] == "en" and parts[1] == "companies":
            return parts[2]
        return None
    slug = host.replace(".pinpointhq.com", "")
    # app.pinpointhq.com is Pinpoint's product login, not a customer board.
    return None if slug in ("", "app") else slug

def fetch_pinpoint_jobs(board_url: str) -> list[dict]:
    slug = resolve_company_slug(board_url)
    if not slug:
        raise ValueError(f"Not a Pinpoint tenant board: {board_url}")

    url = f"https://{slug}.pinpointhq.com/postings.json"
    headers = {"Accept": "application/json", "X-Requested-With": "XMLHttpRequest"}
    try:
        resp = requests.get(url, headers=headers, timeout=15)
        if resp.status_code == 404:
            raise ValueError(f"Pinpoint tenant '{slug}' not found")
        if resp.status_code in (403, 429):
            raise RuntimeError(f"Soft-blocked '{slug}' (HTTP {resp.status_code}) — back off")
        resp.raise_for_status()
        return resp.json().get("data", [])  # [] is valid: tenant has no open roles
    except (RequestException, ValueError) as e:
        print(f"Error fetching {slug}: {e}")
        return []

jobs = fetch_pinpoint_jobs("https://cardfactory.pinpointhq.com")
print(f"Retrieved {len(jobs)} jobs")
Common issues
mediumWrong host resolves to no jobs (app.pinpointhq.com or apply.pinpointhq.com)

app.pinpointhq.com is Pinpoint's shared product/login app, not a tenant board. For the apply host, extract the slug from /en/companies/{slug}; otherwise use the subdomain of {slug}.pinpointhq.com.

highCompany subdomain not found (HTTP 404)

Confirm the exact tenant slug from the careers page URL. An unknown or misspelled subdomain returns 404 rather than an empty result.

lowEmpty data array returned

postings.json returns {"data": []} when a tenant has no open roles (a live and valid state). Treat an empty array as zero jobs, not an error.

lowMissing or hidden compensation

Salary lives in flat compensation_* keys and is only meaningful when compensation_visible is true. Check that flag and guard for null compensation_minimum/compensation_maximum before formatting.

mediumLocation fields vary or are absent for remote roles

location is a top-level object with string fields (name, city, province, postal_code). Prefer name, fall back to city + province, and tolerate nulls for remote or unspecified locations.

mediumIntermittent 403 or 429 responses

No hard rate limit is documented, but aggressive polling can trigger a soft block. Space requests (~5 req/s), and treat 403/429 as a signal to back off and retry with delay.

Best practices
  1. 1Fetch everything from the single postings.json endpoint — descriptions ship inline, so no per-job detail calls are needed
  2. 2Combine description, key_responsibilities, skills_knowledge_expertise, and benefits (with their *_header fields) for the full posting
  3. 3Always check compensation_visible before reading compensation_minimum / compensation_maximum
  4. 4Read location.name first, then fall back to city + province; treat null locations as remote or unspecified
  5. 5Dedupe on the UUID in the posting URL (/postings/{uuid}), falling back to the numeric id when the URL is absent
  6. 6Throttle to roughly 5 requests/second and back off on 403 or 429 to stay under Pinpoint's soft limits
Or skip the complexity

One endpoint. All Pinpoint jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=pinpoint" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access Pinpoint
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.

99.9%API uptime
<200msAvg response
50M+Jobs processed