Ashby Jobs API.
Pull complete postings — full HTML descriptions, pay ranges, departments, and locations — from a single public REST call, with no auth and no pagination to manage.
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 Ashby.
- Full HTML Descriptions
- Structured Pay Ranges
- Department & Team
- Remote Flag & Locations
- Employment Type
- ISO Publish Dates
- 01Startup Job Tracking
- 02Tech Talent Sourcing
- 03Compensation Benchmarking
- 04Remote Role Aggregation
How to scrape Ashby.
Step-by-step guide to extracting jobs from Ashby-powered career pages—endpoints, authentication, and working code.
import requests
company_slug = "limble"
url = f"https://api.ashbyhq.com/posting-api/job-board/{company_slug}"
params = {"includeCompensation": "true"}
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# All jobs returned in a single response - no pagination needed
jobs = [j for j in data["jobs"] if j.get("isListed", True)]
print(f"Found {len(jobs)} active jobs for {company_slug}")for job in jobs:
# Extract address details if available
address = job.get("address", {}).get("postalAddress", {})
job_data = {
"id": job["id"], # UUID format
"title": job["title"],
"department": job.get("department"),
"team": job.get("team"),
"location": job.get("location"),
"city": address.get("addressLocality"),
"country": address.get("addressCountry"),
"is_remote": job.get("isRemote", False),
"employment_type": job.get("employmentType"),
"job_url": job.get("jobUrl"),
"apply_url": job.get("applyUrl"),
"published_at": job.get("publishedAt"),
"salary": job.get("compensation", {}).get("compensationTierSummary"),
"description_html": job.get("descriptionHtml", "")[:200] + "...",
"description_plain": job.get("descriptionPlain", "")[:200] + "...",
}
print(f"{job_data['title']} - {job_data['location']}")import requests
def validate_company(slug: str) -> dict | None:
url = "https://jobs.ashbyhq.com/api/non-user-graphql"
params = {"op": "ApiOrganizationFromHostedJobsPageName"}
payload = {
"operationName": "ApiOrganizationFromHostedJobsPageName",
"variables": {
"organizationHostedJobsPageName": slug,
"searchContext": "JobBoard",
},
"query": """query ApiOrganizationFromHostedJobsPageName(
$organizationHostedJobsPageName: String!,
$searchContext: String
) {
organization(
organizationHostedJobsPageName: $organizationHostedJobsPageName
searchContext: $searchContext
) { name publicWebsite hostedJobsPageSlug allowJobPostIndexing }
}"""
}
resp = requests.post(url, json=payload, params=params, timeout=10)
return resp.json()["data"]["organization"] # None if invalid
# Test validation
org = validate_company("limble")
if org:
print(f"Company: {org['name']}, Website: {org['publicWebsite']}")
else:
print("Invalid company slug")def parse_all_locations(job: dict) -> list:
locations = []
# Primary location
if job.get("location"):
locations.append({
"type": "primary",
"location": job["location"],
"remote": job.get("isRemote", False)
})
# Secondary locations (each entry only carries a location string)
for loc in job.get("secondaryLocations", []):
locations.append({
"type": "secondary",
"location": loc.get("location"),
})
return locations
# Process jobs with all location options
for job in jobs:
all_locations = parse_all_locations(job)
print(f"{job['title']}: {len(all_locations)} location(s)")import time
import requests
def fetch_jobs_batch(slugs: list, delay: float = 0.6) -> dict:
results = {}
for slug in slugs:
url = f"https://api.ashbyhq.com/posting-api/job-board/{slug}"
try:
resp = requests.get(url, params={"includeCompensation": "true"}, timeout=10)
resp.raise_for_status()
data = resp.json()
active_jobs = [j for j in data.get("jobs", []) if j.get("isListed", True)]
results[slug] = active_jobs
print(f"{slug}: {len(active_jobs)} jobs")
except requests.RequestException as e:
print(f"Error fetching {slug}: {e}")
results[slug] = []
time.sleep(delay) # add a delay between boards to avoid 429s
return results
companies = ["limble", "ramp", "notion", "linear"]
all_jobs = fetch_jobs_batch(companies)
print(f"Total: {sum(len(j) for j in all_jobs.values())} jobs")import requests
import time
def scrape_ashby_company(slug: str) -> list[dict]:
# Validate company first
validate_url = "https://jobs.ashbyhq.com/api/non-user-graphql"
validate_resp = requests.post(
validate_url,
json={
"operationName": "ApiOrganizationFromHostedJobsPageName",
"variables": {"organizationHostedJobsPageName": slug, "searchContext": "JobBoard"},
"query": "query ApiOrganizationFromHostedJobsPageName($organizationHostedJobsPageName: String!, $searchContext: String) { organization(organizationHostedJobsPageName: $organizationHostedJobsPageName, searchContext: $searchContext) { name } }"
},
params={"op": "ApiOrganizationFromHostedJobsPageName"},
timeout=10
)
if not validate_resp.json()["data"]["organization"]:
print(f"Invalid company: {slug}")
return []
# Fetch jobs
url = f"https://api.ashbyhq.com/posting-api/job-board/{slug}"
resp = requests.get(url, params={"includeCompensation": "true"}, timeout=10)
resp.raise_for_status()
return [j for j in resp.json().get("jobs", []) if j.get("isListed", True)]
jobs = scrape_ashby_company("limble")
print(f"Scraped {len(jobs)} jobs")The REST API returns {"jobs": []} for an unknown board. Slugs are case-sensitive and some contain spaces (e.g. 'Flock Safety'), so URL-encode them before requesting. Verify with the GraphQL organization endpoint, and check whether the company serves its board from a custom domain instead of jobs.ashbyhq.com.
Not every company exposes salary. Always send includeCompensation=true, then read both compensationTierSummary and scrapeableCompensationSalarySummary, treating null as 'not disclosed'.
Ashby throttles aggressive scraping and returns HTTP 429 (and 403 when blocked); there is no published request budget. Add a short delay between requests and apply exponential backoff on 429.
Jobs with isListed=false are unlisted or draft postings. Always filter them out: [j for j in jobs if j.get('isListed', True)] so you never capture non-public roles.
The GraphQL endpoint returns {"data": {"organization": null}} for an invalid slug. Make sure operationName matches the 'op' query parameter exactly, or fall back to the simpler REST posting-api.
jobs.ashbyhq.com/sitemap.xml serves the app HTML, not an XML sitemap, so sitemap crawling fails. Discover boards from a maintained company list, Google dorks (site:jobs.ashbyhq.com), the Wayback Machine, or links on known career pages.
The response carries apiVersion "1" but is not versioned in the URL, so fields can shift silently. Read every field defensively with .get() and monitor for changes.
- 1Use the REST posting-api endpoint — it returns full descriptions, pay, and locations in one call with no pagination
- 2URL-encode board slugs: they are case-sensitive and some contain spaces (e.g. 'Flock Safety')
- 3Send includeCompensation=true and filter to isListed=true to skip draft and unlisted postings
- 4Add a short delay between requests and back off on HTTP 429 or 403
- 5Validate slugs with the GraphQL organization endpoint before bulk runs
- 6Cache results and refresh daily — boards rarely change intraday
One endpoint. All Ashby jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=ashby" \
-H "X-Api-Key: YOUR_KEY" Access Ashby
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.