All platforms

Apple Jobs Jobs API.

Extract structured roles from Apple's first-party careers platform — a CSRF-guarded JSON search API backed by server-rendered detail pages, unified through Jobo's single jobs endpoint.

Get API access
Apple Jobs
Live
<3haverage discovery time
1hrefresh interval
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 Apple Jobs.

Data fields
  • Full Job Descriptions
  • Team Name & Code
  • Multi-Location Roles
  • Employment & Job Type
  • Remote (Home Office) Flag
  • Posting Dates
Use cases
  1. 01Big Tech Hiring Signals
  2. 02UK Job Market Tracking
  3. 03Team & Org Structure Mapping
  4. 04Job Aggregator Feeds
DIY GUIDE

How to scrape Apple Jobs.

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

HybridadvancedUndocumented — pace ~100ms apart; 429s are rate-limitedNo auth

Bootstrap a CSRF session

Apple's search API rejects anonymous calls. Hit the CSRFToken endpoint first to obtain the X-Apple-CSRF-Token header and the session cookies (jobs, jssid, AWSALBAPP-*), and keep them on a persistent session.

Step 1: Bootstrap a CSRF session
import requests

session = requests.Session()

boot = session.get(
    "https://jobs.apple.com/api/v1/CSRFToken",
    headers={"Accept": "application/json"},
)
boot.raise_for_status()

# The token comes back on a response header; the cookies stay on the session.
csrf_token = boot.headers["X-Apple-CSRF-Token"]

Query the search API

POST to /api/v1/search with the CSRF token, session cookies, and a JSON body. The board is scoped to the UK, so the location filter is postLocation-GBR. Results arrive under the res envelope.

Step 2: Query the search API
def fetch_page(session, csrf_token, page):
    resp = session.post(
        "https://jobs.apple.com/api/v1/search",
        headers={
            "Accept": "application/json",
            "Content-Type": "application/json",
            "X-Apple-CSRF-Token": csrf_token,
            "Origin": "https://jobs.apple.com",
            "Referer": "https://jobs.apple.com/en-gb/search?location=united-kingdom-GBR",
        },
        json={
            "query": "",
            "filters": {"locations": ["postLocation-GBR"]},
            "page": page,
            "locale": "en-gb",
            "sort": "",
            "format": {"longDate": "MMMM D, YYYY", "mediumDate": "MMM D, YYYY"},
        },
    )
    resp.raise_for_status()
    return resp.json()["res"]

Paginate and dedupe on positionId

Pages hold 20 rows; keep going while page * 20 < totalRecords. The same role can appear under several location-indexed reqId suffixes, so dedupe on the numeric positionId — the canonical, stable job key.

Step 3: Paginate and dedupe on positionId
PAGE_SIZE = 20

def fetch_all(session, csrf_token):
    seen, jobs, page = set(), [], 1
    while True:
        res = fetch_page(session, csrf_token, page)
        rows = res.get("searchResults", [])
        total = res.get("totalRecords", 0)
        for row in rows:
            position_id = row.get("positionId")
            if not position_id or position_id in seen:
                continue
            seen.add(position_id)
            jobs.append(row)
        if not rows or page * PAGE_SIZE >= total:
            break
        page += 1
    return jobs

Build canonical URLs from the row

Compose the detail and apply URLs from positionId plus transformedPostingTitle (the URL slug), and read the flat fields the search response already provides — title, team, locations, and posting date.

Step 4: Build canonical URLs from the row
from urllib.parse import quote

def to_record(row):
    position_id = row["positionId"]
    slug = row.get("transformedPostingTitle") or "job"
    team = row.get("team") or {}
    return {
        "id": position_id,
        "title": row.get("postingTitle"),
        "team": team.get("teamName"),
        "team_code": team.get("teamCode"),
        "locations": [loc.get("name") for loc in row.get("locations", [])],
        "posted_at": row.get("postDateInGMT") or row.get("postingDate"),
        "url": f"https://jobs.apple.com/en-gb/details/{quote(position_id)}/{quote(slug)}",
        "apply_url": f"https://jobs.apple.com/app/en-gb/apply/{quote(position_id)}",
    }

Parse full descriptions from the detail page

There is no anonymous detail JSON endpoint. Fetch the server-rendered job page and pull the double-encoded window.__staticRouterHydrationData blob, then read loaderData.jobDetails.jobsData for the description sections.

Step 5: Parse full descriptions from the detail page
import json, re

HYDRATION = re.compile(
    r'window\.__staticRouterHydrationData\s*=\s*JSON\.parse\('
    r'(?P<json>"(?:\\.|[^"\\])*")\);'
)

def fetch_details(session, detail_url):
    html = session.get(detail_url, headers={"Accept": "text/html"}).text
    match = HYDRATION.search(html)
    if not match:
        raise ValueError("Apple detail page omitted hydration data")

    # The payload is a JSON string literal wrapping the real JSON document.
    payload = json.loads(json.loads(match.group("json")))
    job = payload["loaderData"]["jobDetails"]["jobsData"]
    return {
        "title": job.get("postingTitle"),
        "employment_type": job.get("employmentType"),
        "summary": job.get("jobSummary"),
        "responsibilities": job.get("responsibilities"),
        "minimum_qualifications": job.get("minimumQualifications"),
        "preferred_qualifications": job.get("preferredQualifications"),
    }
Common issues
criticalSearch API returns 401/403 for anonymous requests

Bootstrap /api/v1/CSRFToken before searching and forward both the X-Apple-CSRF-Token header and the returned session cookies (jobs, jssid, AWSALBAPP-*) on every call. A single requests.Session handles the cookies automatically.

highJob descriptions are missing from the search response

The search API only returns a short jobSummary. Full descriptions, responsibilities, and qualifications live in the window.__staticRouterHydrationData blob on the server-rendered detail page — parse it from loaderData.jobDetails.jobsData.

mediumThe same role appears multiple times

Multi-location postings emit one search row per location with a different reqId suffix (e.g. 200671886-5682). Dedupe on the numeric positionId, which is identical across every duplicate row.

mediumreqId values carry PIPE-/REQ- prefixes that break ID matching

Detail hydration reports the requisition as PIPE-{positionId} or REQ-{positionId}, not the raw key. Treat positionId as the canonical identifier and ignore the reqId prefix when linking search rows to detail pages.

mediumBursts of detail fetches return HTTP 429

Apple rate-limits rapid requests. Pace calls roughly 100ms apart, keep detail concurrency low (around 6), and back off on 429 responses.

Best practices
  1. 1Bootstrap /api/v1/CSRFToken first and reuse its token plus cookies via a persistent requests.Session.
  2. 2Key jobs on the numeric positionId — it is stable across the search, detail, and apply routes.
  3. 3Dedupe search rows on positionId to drop location-indexed duplicates.
  4. 4Paginate in pages of 20 and stop once page * 20 >= totalRecords.
  5. 5Read full descriptions from the detail page hydration blob; there is no separate detail JSON API.
  6. 6Throttle detail requests (~100ms apart, low concurrency) to avoid 429 responses.
Or skip the complexity

One endpoint. All Apple Jobs jobs. No scraping, no sessions, no maintenance.

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

Access Apple Jobs
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