# Freshteam Jobs API: Get Freshteam Job Listings as JSON

> Freshteam career sites have no public jobs JSON API, but each job page carries JSON-LD. How to list jobs, parse them as JSON, and fix the swapped fields.

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

TL;DR: Freshteam has no public JSON API for job listings. The career site at {company}.freshteam.com/jobs renders every open job as a link in one HTML page, and each job page has a schema.org JobPosting JSON-LD block. Three quirks matter: the description is HTML-entity-encoded inside the JSON, remote is the string "true" or "false", and addressRegion and addressLocality are swapped, so the city sits in addressRegion.

To get a company's jobs from Freshteam, fetch `https://{company}.freshteam.com/jobs` for the full list of job links, then read the schema.org `JobPosting` JSON-LD on each job page to get the job as JSON. There's no public jobs API and no key involved: both pages are public HTML.

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 Freshteam 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}.freshteam.com/jobs` (HTML list of job links) |
| Detail | `GET https://{company}.freshteam.com/jobs/{id}/{slug}` (JSON-LD) |
| API key | None |
| Pagination | None: the page lists every job |
| Full description | Job page JSON-LD, HTML-entity-encoded |
| Salary field | No |
| Remote | JSON-LD `remote`, as the string `"true"` or `"false"` |

## Finding the Company Subdomain

Freshteam career sites live at `https://{company}.freshteam.com/jobs`, so for `acme.freshteam.com` the subdomain is `acme`. Job pages look like `https://acme.freshteam.com/jobs/{id}/{slug}`.

## Step 1: List the Jobs

```bash
curl "https://acme.freshteam.com/jobs"
```

The page is server-rendered and contains every open job, grouped by department. Collect the job links:

```html
<a href="/jobs/7ezUlJvsu_sw/embedded-software-engineer">...</a>
```

The ID is the segment after `/jobs/`. IDs can contain hyphens and underscores (`7ezUlJvsu_sw`, `XUzaB5-SnZml`), so a pattern that only allows letters and digits silently misses some jobs. Compare your count with the job count the page shows to catch that.

Filters in the query string, such as a department filter, only change what's displayed in the browser. The HTML always has the full list, so there's nothing to paginate.

## Step 2: Read Each Job's JSON-LD

```bash
curl "https://acme.freshteam.com/jobs/7ezUlJvsu_sw/embedded-software-engineer"
```

Each job page has a `<script type="application/ld+json">` block (trimmed):

```json
{
  "@context": "http://schema.org/",
  "@type": "JobPosting",
  "url": "https://acme.freshteam.com/jobs/7ezUlJvsu_sw/Embedded%20Software%20Engineer",
  "title": "Embedded Software Engineer",
  "description": "&lt;div&gt;&lt;strong&gt;Job Description&lt;/strong&gt;&lt;/div&gt;...",
  "datePosted": "2026-09-24 07:39:49 UTC",
  "employmentType": "CONTRACTOR",
  "remote": "false",
  "hiringOrganization": { "@type": "Organization", "name": "Acme Inc." },
  "jobLocation": {
    "@type": "Place",
    "address": {
      "@type": "PostalAddress",
      "streetAddress": "",
      "addressRegion": "Bengaluru",
      "postalCode": "",
      "addressLocality": "Karnataka",
      "addressCountry": "India"
    }
  }
}
```

## JSON-LD Fields

| Field | What it holds |
|---|---|
| `title` | Job title |
| `description` | HTML, but entity-encoded (`&lt;div&gt;`) |
| `datePosted` | Date and time, e.g. `2026-09-24 07:39:49 UTC` |
| `employmentType` | schema.org value such as `FULL_TIME` or `CONTRACTOR` |
| `remote` | The string `"true"` or `"false"` |
| `jobLocation.address.addressRegion` | The **city** (swapped, see below) |
| `jobLocation.address.addressLocality` | The **state or region** (swapped) |
| `jobLocation.address.addressCountry` | Full country name, such as `India` |
| `hiringOrganization.name` | The employer's name |
| `url` | Job page URL |

There's no department and no salary in the JSON-LD.

## Fetching Jobs (Python)

```python
import html
import json
import re
import time
from datetime import datetime, timezone
import requests

LINK_RE = re.compile(r'href="/jobs/([A-Za-z0-9_-]+)/[^"]*"')
JSONLD_RE = re.compile(r'<script type="application/ld\+json">(.*?)</script>', re.S)

def job_posting_from_html(page: str):
    for block in JSONLD_RE.findall(page):
        try:
            data = json.loads(block)
        except ValueError:
            continue
        if data.get("@type") == "JobPosting":
            return data
    return None

def fetch_freshteam_jobs(company: str) -> list:
    list_html = requests.get(f"https://{company}.freshteam.com/jobs", timeout=25).text
    if re.search(r"we couldn.t find|claim it now", list_html, re.I):
        raise ValueError(f"{company}.freshteam.com is not an active career site")
    ids = list(dict.fromkeys(LINK_RE.findall(list_html)))

    jobs = []
    for job_id in ids:
        # The slug is ignored by Freshteam; any value works
        r = requests.get(f"https://{company}.freshteam.com/jobs/{job_id}/x", timeout=25)
        if r.status_code == 404:
            continue
        r.raise_for_status()
        ld = job_posting_from_html(r.text)
        if not ld:
            continue
        addr = (ld.get("jobLocation") or {}).get("address") or {}
        posted = ld.get("datePosted")
        jobs.append({
            "id": job_id,
            "title": (ld.get("title") or "").strip(),
            "date_posted": datetime.strptime(posted, "%Y-%m-%d %H:%M:%S UTC").replace(tzinfo=timezone.utc).isoformat() if posted else None,
            "employment_type": ld.get("employmentType"),
            "remote": ld.get("remote") in (True, "true"),
            "city": addr.get("addressRegion") or None,      # swapped on Freshteam
            "state": addr.get("addressLocality") or None,   # swapped on Freshteam
            "country": addr.get("addressCountry") or None,
            "company": (ld.get("hiringOrganization") or {}).get("name"),
            "url": ld.get("url"),
            "html": html.unescape(ld.get("description") or ""),
        })
        time.sleep(0.25)
    return jobs
```

## Gotchas

**City and state are swapped.** schema.org defines `addressLocality` as the city and `addressRegion` as the state. Freshteam fills them the other way around, on every career site we've checked: `addressRegion` holds the city and `addressLocality` the state. If you read them the standard way, Bengaluru ends up as a state and Karnataka as a city. Swap them once in your parser. See the [location normalization guide](https://cleanjobdata.com/guides/job-location-normalization).

**The description is entity-encoded.** It's HTML, but escaped inside the JSON string: `&lt;div&gt;` instead of `<div>`. Unescape it once before storing. Some HTML libraries re-escape the result when you serialize it again, so check that the stored text contains real `<` characters.

**`remote` is a string.** It's `"true"` or `"false"`, not a boolean. In most languages the string `"false"` is truthy, so `bool(ld["remote"])` marks every job as remote. Compare with the string instead.

**Country is a full name.** `addressCountry` is `India`, not `IN`, so you need a name-to-code lookup.

**A dead career site still returns 200.** A company that has left Freshteam can leave behind a page that says the site couldn't be found and offers to let you claim it. It returns HTTP 200, so check the text rather than the status before treating the board as empty.

**A missing job returns 404.** That's a clean signal the job is gone. Otherwise, treat a job as closed when it disappears from a complete list. See [syncing job data in depth](https://cleanjobdata.com/guides/syncing-job-data-in-depth).

**There's no salary field.** If pay is disclosed, it's in the description text.

**One request per job.** The list page gives only job links grouped by department, with no date or location, so every new job costs a page fetch. Fetch new IDs only and keep the request rate modest.

## Public Pages vs Freshteam's Official API

Freshteam has an official REST API for its customers. It needs an API key from the employer's own Freshteam account and covers that company's hiring data. For published job ads from companies you don't work with, the public career site and its JSON-LD are the source to use.

## When This Is Enough

For one company, one list page plus a JSON-LD read per job is simple, as long as you handle the swapped address fields and the encoded description.

## When It Isn't

Across many companies and platforms you end up maintaining a different collector for each, and many of them have quirks like Freshteam's swapped fields that only show up when you check real postings. The [ATS public job postings API list](https://cleanjobdata.com/articles/ats-public-job-postings-api-list) covers the differences.

CleanJobData reads Freshteam career sites alongside other platforms and returns one schema, with locations resolved to cities, states and countries, a remote type and an employment type. 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 Freshteam 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.freshteam.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`. Freshteam jobs apply on each company's own `freshteam.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 Freshteam have a public jobs API?

Not for reading other companies' job listings. Freshteam's REST API needs an API key from the employer's account. Public job data comes from the career site at `{company}.freshteam.com/jobs` and the schema.org `JobPosting` JSON-LD on each job page.

### Why are city and state reversed in Freshteam's JSON-LD?

Freshteam puts the city in `addressRegion` and the state in `addressLocality`, the opposite of the schema.org definitions. It happens on every Freshteam career site we've checked, so swap the two fields once in your parser.

### How do I decode the Freshteam job description?

The `description` in the JSON-LD is HTML escaped as entities, such as `&lt;p&gt;`. Run it through an HTML unescape function once, for example `html.unescape` in Python, to get normal HTML.

### Does Freshteam include salary data?

No. The JSON-LD on Freshteam job pages has no `baseSalary`, so pay only appears when the employer writes it into the description.
