All platforms

Recooty Jobs API.

Enumerate every open role on an SMB careers board through per-host sitemaps, then enrich each job with server-rendered HTML fields and JSON-LD metadata — no login, no hidden API.

Get API access
Recooty
Live
15K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
Developer tools

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 Recooty.

Data fields
  • Full HTML Job Descriptions
  • Employment & Experience Level
  • Structured Location Fields
  • Posted & Expiry Dates
  • On-Site / Remote Type
  • Direct Apply URLs
Use cases
  1. 01SMB Job Monitoring
  2. 02Sitemap-Based Job Discovery
  3. 03Structured Data Extraction
  4. 04Recruiting Market Research
DIY GUIDE

How to scrape Recooty.

Step-by-step guide to extracting jobs from Recooty-powered career pages—endpoints, authentication, and working code.

Hybridintermediate~120 requests/minute shared host ceiling; 1 concurrent detail request, ~750ms apart, with 429 Retry-After backoffNo auth

Enumerate a company's jobs from the sitemap

One sitemap per host lists every live job — fetch it once, filter the <loc> entries for your target company, and skip paging through board HTML.

Step 1: Enumerate a company's jobs from the sitemap
import requests
import xml.etree.ElementTree as ET
from urllib.parse import urlparse

def list_company_jobs(host, company_slug):
    """Enumerate a company's job URLs from the per-host sitemap.

    Recooty publishes one sitemap per host listing every job on the domain.
    Keep only <loc> entries whose first path segment is this company and that
    point at a job detail page: exactly /{company}/{job-slug}, never /apply."""
    sitemap_url = f"https://{host}/sitemap.xml"
    resp = requests.get(sitemap_url, timeout=30)
    resp.raise_for_status()

    root = ET.fromstring(resp.content)
    listings = []
    seen = set()

    for loc in root.iter():
        # Sitemaps may or may not declare a namespace; match on the local name.
        if loc.tag.split("}")[-1] != "loc":
            continue
        url = (loc.text or "").strip()
        parsed = urlparse(url)
        if parsed.netloc.lower() != host.lower():
            continue

        segments = [s for s in parsed.path.split("/") if s]
        # Job detail pages are exactly /{company}/{job-slug}. Skip the board
        # root (1 segment), deeper paths, and /apply URLs.
        if len(segments) != 2 or segments[0].lower() != company_slug.lower():
            continue
        if segments[1].lower() == "apply":
            continue

        external_id = segments[1]  # e.g. frontend-developer-reactjs-re144
        if external_id in seen:
            continue
        seen.add(external_id)

        canonical = f"https://{host}/{segments[0]}/{segments[1]}"
        listings.append({
            "external_id": external_id,
            "listing_url": canonical,
            "apply_url": canonical + "/apply/",
        })

    return listings

jobs = list_company_jobs("careerspage.io", "recooty-inc")
print(f"Found {len(jobs)} jobs")

Fetch each job detail page

Recooty renders everything server-side, so the listing URL is also the detail page — there is no separate detail endpoint. Send the board URL as the Referer and accept HTML.

Step 2: Fetch each job detail page
import requests

def fetch_detail_html(listing_url, board_url):
    """Fetch a job detail page. The canonical listing URL IS the detail page;
    Recooty has no structured detail JSON endpoint."""
    resp = requests.get(
        listing_url,
        headers={
            "Accept": "text/html,application/xhtml+xml",
            "Referer": board_url,
        },
        timeout=30,
    )
    resp.raise_for_status()
    return resp.text

html = fetch_detail_html(jobs[0]["listing_url"], "https://careerspage.io/recooty-inc")
print(f"Fetched {len(html)} bytes")

Parse the detail HTML fields

Extract title, location, description, sidebar metadata, and the apply link. Recooty renders sidebar fields keyed off FontAwesome icons rather than text labels, and templates vary — use fallback selector pairs.

Step 3: Parse the detail HTML fields
from bs4 import BeautifulSoup

def parse_detail_html(html):
    soup = BeautifulSoup(html, "html.parser")

    def text_of(*selectors):
        for sel in selectors:
            el = soup.select_one(sel)
            if el:
                t = el.get_text(strip=True)
                if t:
                    return t
        return None

    # Title and location fall back from the scoped selector to the bare one.
    title = text_of("div.pt-9 h3.font-size-6", "h3.font-size-6")
    location = text_of("div.pt-9 span.font-size-3.text-gray",
                       "span.font-size-3.text-gray")

    # Description is the inner HTML of company-profile (fallback job-details-content).
    body = (soup.select_one("div.company-profile")
            or soup.select_one("div.job-details-content"))
    description_html = body.decode_contents().strip() if body else ""

    # Sidebar metadata is keyed off FontAwesome icons, not text labels.
    def meta_for(*icon_classes):
        for icon in icon_classes:
            i = soup.select_one(f"i.{icon}")
            if not i:
                continue
            container = i.find_parent(["p", "div", "li"])
            if container:
                val = container.get_text(" ", strip=True)
                if val:
                    return val
        return None

    employment_type = meta_for("fa-briefcase")
    experience_level = meta_for("fa-business-time", "fa-user-tie")
    location_type = meta_for("fa-location-arrow")

    apply_el = (soup.select_one('a.btn-green[href*="/apply"]')
                or soup.select_one('a.btn[href*="/apply"]'))
    apply_url = apply_el["href"] if apply_el and apply_el.has_attr("href") else None

    return {
        "title": title,
        "location": location,
        "description_html": description_html,
        "employment_type": employment_type,
        "experience_level": experience_level,
        "location_type": location_type,
        "apply_url": apply_url,
    }

detail = parse_detail_html(html)
print(detail["title"], detail["location"])

Read JSON-LD fields defensively

Detail pages embed a JobPosting JSON-LD block, but Recooty's ld+json is frequently invalid JSON (raw newlines and unescaped HTML inside the description) so json.loads() raises. Pull the well-formed top-level string fields with regex instead of parsing the whole object.

Step 4: Read JSON-LD fields defensively
import re

# Recooty's application/ld+json is frequently INVALID json (raw newlines and
# unescaped HTML inside "description"), so json.loads() raises. Pull the
# well-formed top-level string fields with regex instead of parsing the object.
LD_BLOCK = re.compile(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>',
                      re.IGNORECASE | re.DOTALL)

def _ld_string(body, key):
    m = re.search(r'"' + re.escape(key) + r'"\s*:\s*"((?:\\.|[^"\\])*)"', body)
    return m.group(1) if m else None

def _ld_object(body, key):
    # Scope an inner object (assumes no nested braces, true for Recooty blocks).
    m = re.search(r'"' + re.escape(key) + r'"\s*:\s*\{([^{}]*)\}', body)
    return m.group(1) if m else None

def extract_jsonld_fields(html):
    for block in LD_BLOCK.finditer(html):
        body = block.group(1)
        if "JobPosting" not in body:
            continue
        # hiringOrganization.name is scoped so it never collides with a
        # Place/PostalAddress "name" elsewhere in the block.
        org = _ld_object(body, "hiringOrganization")
        return {
            "company_name": _ld_string(org, "name") if org else None,
            "date_posted": _ld_string(body, "datePosted"),
            "valid_through": _ld_string(body, "validThrough"),
            "employment_type": _ld_string(body, "employmentType"),
            "experience_requirements": _ld_string(body, "experienceRequirements"),
            "job_location_type": _ld_string(body, "jobLocationType"),
            "address_locality": _ld_string(body, "addressLocality"),
            "address_region": _ld_string(body, "addressRegion"),
            "address_country": _ld_string(body, "addressCountry"),
        }
    return {}

ld = extract_jsonld_fields(html)
print(ld.get("date_posted"), ld.get("valid_through"))

Respect rate limits and drive the full flow

Recooty's shared host ceiling is around 120 requests/minute; exceeding it returns HTTP 429. Keep one detail request in flight, space requests ~750ms apart, and honor Retry-After on 429 before retrying.

Step 5: Respect rate limits and drive the full flow
import time
import requests

def get_with_backoff(url, headers=None, max_attempts=3):
    """On HTTP 429, honor Retry-After, falling back to 2s then 5s (clamped)."""
    fallback = [2.0, 5.0]
    resp = None
    for attempt in range(max_attempts):
        resp = requests.get(url, headers=headers, timeout=30)
        if resp.status_code != 429 or attempt == max_attempts - 1:
            return resp
        retry_after = resp.headers.get("Retry-After")
        try:
            delay = float(retry_after) if retry_after else fallback[min(attempt, 1)]
        except ValueError:
            delay = fallback[min(attempt, 1)]
        time.sleep(max(0.5, min(delay, 30.0)))
    return resp

def scrape_company(host, company_slug):
    board_url = f"https://{host}/{company_slug}"
    results = []
    for job in list_company_jobs(host, company_slug):
        resp = get_with_backoff(
            job["listing_url"],
            headers={"Accept": "text/html,application/xhtml+xml", "Referer": board_url},
        )
        if not resp.ok:
            continue  # 404 -> removed; 403/429 -> throttled
        detail = parse_detail_html(resp.text)
        detail.update(extract_jsonld_fields(resp.text))
        detail["external_id"] = job["external_id"]
        detail["listing_url"] = job["listing_url"]
        detail["apply_url"] = detail.get("apply_url") or job["apply_url"]
        results.append(detail)
        time.sleep(0.75)  # 1 request at a time, 750ms apart
    return results

all_jobs = scrape_company("careerspage.io", "recooty-inc")
print(f"Scraped {len(all_jobs)} jobs")
Common issues
highJSON-LD is frequently invalid JSON

Recooty's application/ld+json block often contains raw newlines and unescaped HTML inside the description, so a standard JSON parser throws. Extract individual top-level string fields (datePosted, validThrough, employmentType, hiringOrganization.name) with regex instead of json.loads().

highNo structured detail endpoint

Probing the guessed /api/{tenant}/jobs route returns 404 and {tenant}/jobs.json redirects back to the HTML board. Job detail data only exists as JSON-LD embedded in the server-rendered detail page, so drive listings from sitemap.xml and parse each detail page's HTML plus JSON-LD.

mediumTemplates drift between boards

Newer templates render sidebar values as plain text inside a <p> while older ones wrap them in <span>, and the posted-date attribute appears in either order. Use fallback selector pairs (company-profile/job-details-content, fa-user-tie/fa-business-time) and match both variants; JSON-LD fields are more stable than Bootstrap utility classes.

mediumSame slug on two different hosts

careerspage.io and jobs.recooty.com can host different boards under the same company slug. Scope job identity to the vendor host so a legacy careerspage.io board never expires a separately hosted jobs.recooty.com board with the same slug.

mediumRate limiting on the shared host

The host advertises roughly 120 requests/minute and returns HTTP 429 when exceeded; 403 also indicates blocking. Honor Retry-After and RateLimit-Reset headers, cap concurrency at one detail request, and space requests ~750ms apart.

lowEmpty boards and placeholder locations

Some companies have no active jobs, and location cards contain placeholders such as 'refer to job advertisement', 'various', or 'n/a'. Handle empty sitemaps gracefully and filter known placeholder strings before parsing locations.

Best practices
  1. 1Enumerate jobs from the per-host sitemap.xml rather than parsing board HTML
  2. 2Parse JSON-LD with field-level regex — Recooty's ld+json is often invalid JSON
  3. 3Scope identity per host so a careerspage.io board never expires a jobs.recooty.com board with the same slug
  4. 4Keep ~750ms between detail requests and one request in flight to stay under the host ceiling
  5. 5Honor Retry-After and RateLimit-Reset headers on HTTP 429 before retrying
  6. 6Fall back through selector pairs (company-profile/job-details-content, fa-user-tie/fa-business-time) for template drift
Or skip the complexity

One endpoint. All Recooty jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=recooty" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access Recooty
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.

99.9%API uptime
<200msAvg response
50M+Jobs processed