Freshteam Jobs API: Get Freshteam Job Listings as JSON
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
| 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
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": "<div><strong>Job Description</strong></div>...",
"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 (<div>) |
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)
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 jobsGotchas
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: <div> 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.
- Jobs API reference for every filter and response field
- Playground to run queries without writing code
- 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 <p>. 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.