Kula Jobs API.
Pull every open role from a growth-stage recruiting platform in a single unauthenticated REST call, with full HTML descriptions, structured pay ranges, and office locations already in the payload.
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 Kula.
- Full HTML job descriptions
- Structured pay ranges
- Office locations with city, state & country
- Workplace type (office, remote, hybrid)
- Department names
- Employment type
- 01Growth-Stage Company Job Tracking
- 02Tech Talent Sourcing
- 03Compensation Benchmarking
- 04Job Board Aggregation
How to scrape Kula.
Step-by-step guide to extracting jobs from Kula-powered career pages—endpoints, authentication, and working code.
import re
def extract_account_name(url: str) -> str:
"""Extract account name from a Kula career page URL."""
pattern = r'careers\.kula\.ai/([^/]+)'
match = re.search(pattern, url)
if match:
return match.group(1)
raise ValueError(f"Invalid Kula URL: {url}")
# Example usage
url = "https://careers.kula.ai/covergenius"
account_name = extract_account_name(url)
print(f"Account name: {account_name}") # Output: covergeniusimport requests
def fetch_kula_jobs(account_name: str, page: int = 1, items: int = 99) -> dict:
"""Fetch a page of jobs from the Kula API."""
url = "https://careers.kula.ai/api/internal/ats_job_posts"
params = {
"accountName": account_name,
"page": page,
"type": "ats_job_post.index",
"items": items,
}
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
return response.json()
# Fetch the first page for a company
data = fetch_kula_jobs("covergenius")
meta = data.get("meta", {})
print(f"Found {meta.get('count')} jobs across {meta.get('pages')} page(s)")def parse_job(job: dict, account_name: str) -> dict:
"""Parse a single job from the Kula API response."""
ats_job = job.get("ats_job", {}) or {}
# Prefer the full location string; fall back to Remote when no offices.
offices = ats_job.get("offices", []) or []
location = offices[0].get("location", "") if offices else "Remote"
base_salary = (ats_job.get("compensation", {}) or {}).get("base_salary", {}) or {}
return {
"id": job.get("id"),
"title": job.get("title"),
"department": (ats_job.get("ats_department", {}) or {}).get("name"),
"location": location,
"workplace_type": ats_job.get("workplace"), # office, remote, hybrid
"employment_type": ats_job.get("employment_type"),
"salary_min": base_salary.get("min_amount"),
"salary_max": base_salary.get("max_amount"),
"salary_currency": base_salary.get("currency"),
"description_html": ats_job.get("job_description"),
"is_listed": job.get("listed", False),
"is_confidential": job.get("is_confidential", False),
"url": f"https://careers.kula.ai/{account_name}/{job.get('id')}",
}
# Parse every job on the page
for job in data.get("data", []):
parsed = parse_job(job, "covergenius")
print(f"{parsed['title']} - {parsed['location']}")def fetch_all_kula_jobs(account_name: str) -> list:
"""Fetch all jobs across every page."""
all_jobs = []
page = 1
while True:
data = fetch_kula_jobs(account_name, page=page)
all_jobs.extend(data.get("data", []))
total_pages = data.get("meta", {}).get("pages", 1)
if page >= total_pages:
break
page += 1
return all_jobs
all_jobs = fetch_all_kula_jobs("covergenius")
print(f"Total jobs fetched: {len(all_jobs)}")def filter_active_jobs(jobs: list) -> list:
"""Keep only publicly listed, non-confidential jobs."""
return [
job for job in jobs
if job.get("listed") is True and job.get("is_confidential") is False
]
active_jobs = filter_active_jobs(fetch_all_kula_jobs("covergenius"))
print(f"Active public jobs: {len(active_jobs)}")The account name must match the first path segment of careers.kula.ai/{accountName} exactly. Re-derive it from the live board URL rather than guessing.
Treat 403 and 429 as rate limiting: pause page requests roughly 200ms apart and apply exponential backoff before retrying rather than hammering the endpoint.
The company may simply have no open roles, or the account name may be wrong. Check meta.count and confirm the board loads in a browser before assuming a scraper fault.
job_description, offices and compensation can be absent on some roles. Default nested lookups to empty dict/list and null-check before use to avoid KeyError crashes.
/api/internal/ats_job_posts is an internal endpoint with no public contract. Validate the response shape (data plus meta) on each run and alert when the structure shifts.
- 1Call the JSON API instead of scraping HTML for reliable, structured data
- 2Request 99 items per page to minimize pagination round-trips
- 3Skip jobs where listed is false or is_confidential is true
- 4Iterate the offices array to capture every location on multi-site roles
- 5Space page requests ~200ms apart and back off on HTTP 403/429
- 6Cache results and refresh daily, since boards update infrequently
One endpoint. All Kula jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=kula" \
-H "X-Api-Key: YOUR_KEY" Access Kula
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.