Freshteam Jobs API: Get Freshteam Job Listings as JSON

CleanJobData Engineering

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.

On this page

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.

At a Glance

EndpointGET https://{company}.freshteam.com/jobs (HTML list of job links)
DetailGET https://{company}.freshteam.com/jobs/{id}/{slug} (JSON-LD)
API keyNone
PaginationNone: the page lists every job
Full descriptionJob page JSON-LD, HTML-entity-encoded
Salary fieldNo
RemoteJSON-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

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

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

<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

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

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

{
  "@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

FieldWhat it holds
titleJob title
descriptionHTML, but entity-encoded (&lt;div&gt;)
datePostedDate and time, e.g. 2026-09-24 07:39:49 UTC
employmentTypeschema.org value such as FULL_TIME or CONTRACTOR
remoteThe string "true" or "false"
jobLocation.address.addressRegionThe city (swapped, see below)
jobLocation.address.addressLocalityThe state or region (swapped)
jobLocation.address.addressCountryFull country name, such as India
hiringOrganization.nameThe employer's name
urlJob page URL

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

Fetching Jobs (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.

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.

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 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 or see the API Documentation.

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.

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.

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.