Jobsoid Jobs API.

Pull a Jobsoid tenant's entire published board from one documented JSON call, with descriptions, departments, salary text and apply URLs already structured on every row.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Jobsoid.

Data fields

  • Complete Board In One Call
  • Full HTML Descriptions
  • Department & Function
  • Job Type and Positions
  • Experience and Salary Text
  • Posted and Closing Dates

Use cases

  1. 01SMB & Mid-Market Job Aggregation
  2. 02Multi-Country Employer Feeds
  3. 03Careers Page Extraction
  4. 04ATS Data Pipelines

Trusted by

  • Winterberry
  • BRAVE Church
  • VIB
  • WebBeds
DIY GUIDE

How to scrape Jobsoid.

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

API type
REST
Difficulty
beginner
Rate limit
No published limit; ~100ms between requests and at most 3 concurrent detail calls
Authentication
No auth

Take the tenant from the subdomain

Each Jobsoid customer publishes at {tenant}.jobsoid.com, and that host stays the stable canonical board even when the employer fronts it with a custom domain. Detail routes are /j/{jobId}/{slug} and /apply/{jobId}. Exclude the vendor's own labels — portal, www, api and portal-api are not tenants.

Step 1: Take the tenant from the subdomain
import re
from urllib.parse import urlparse

SUFFIX = ".jobsoid.com"
RESERVED = {"portal", "www", "api", "portal-api"}
TENANT = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)
JOB_ID = re.compile(r"^[1-9][0-9]*$")

def parse_hosted(url: str) -> tuple[str, str | None] | None:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    if not host.endswith(SUFFIX):
        return None
    tenant = host[: -len(SUFFIX)]
    if tenant in RESERVED or "." in tenant or not TENANT.match(tenant):
        return None

    parts = [p for p in parsed.path.split("/") if p]
    if not parts or parts == ["jobs"]:
        return tenant, None                                   # the board
    if parts[0] in ("j", "apply") and JOB_ID.match(parts[1] if len(parts) > 1 else ""):
        return tenant, parts[1]
    return None

def board_url(tenant: str) -> str:
    return f"https://{tenant}.jobsoid.com/"

print(parse_hosted("https://webbeds.jobsoid.com/j/136095/market-manager-korea"))

Fetch the published jobs collection

Jobsoid documents /api/v1/jobs as the published-job interface, and the tenant collection is an unpaginated array — one request returns everything. An empty array is an authoritative empty board, so it is safe to treat as 'this tenant has no open roles' rather than as a failed request.

Step 2: Fetch the published jobs collection
import requests

def fetch_jobs(session: requests.Session, tenant: str) -> list[dict]:
    response = session.get(
        f"https://{tenant}.jobsoid.com/api/v1/jobs",
        headers={"Accept": "application/json"},
        timeout=60,
    )
    response.raise_for_status()
    rows = response.json()
    if not isinstance(rows, list):
        raise RuntimeError("Jobsoid listings API omitted its array")
    return rows  # [] is an authoritative empty board

session = requests.Session()
rows = fetch_jobs(session, "winterberry")
print(f"{len(rows)} published jobs")

Map rows and rebuild canonical URLs

Each row carries id, title, slug, description, company, location, department, function, type, positions, experience, salary and the two date fields. Rebuild the listing URL from the tenant, id and slug so identity stays stable, and keep the provider's applyUrl only when it is an HTTPS address — some tenants point it at a configured custom domain.

Step 3: Map rows and rebuild canonical URLs
def build_location(location: dict | None) -> str | None:
    if not location:
        return None
    parts = [location.get("title") or location.get("city"),
             location.get("state"), location.get("country")]
    return ", ".join(p for p in parts if p) or None

def map_row(row: dict, tenant: str) -> dict | None:
    job_id, slug, title = row.get("id"), (row.get("slug") or "").lower(), (row.get("title") or "").strip()
    if not job_id or not slug or not title:
        return None

    apply_url = (row.get("applyUrl") or "").strip()
    if not apply_url.startswith("https://"):
        apply_url = f"https://{tenant}.jobsoid.com/apply/{job_id}"

    return {
        "id": str(job_id),
        "title": title,
        "company": (row.get("company") or "").strip() or None,
        "description_html": (row.get("description") or "").strip() or None,
        "location": build_location(row.get("location")),
        "department": (row.get("department") or {}).get("title"),
        "function": (row.get("function") or {}).get("title"),
        "job_code": row.get("code"),
        "job_type": row.get("type"),
        "positions": row.get("positions"),
        "experience": row.get("experience"),
        "salary": row.get("salary"),
        "posted_at": row.get("postedDate"),
        "closes_at": row.get("closingDate"),
        "listing_url": f"https://{tenant}.jobsoid.com/j/{job_id}/{slug}",
        "apply_url": apply_url,
    }

listings = [m for m in (map_row(r, "winterberry") for r in rows) if m]
print(f"{len(listings)} mapped of {len(rows)} received")

Re-read a single job and handle the 204 response

The per-job resource /api/v1/jobs/{jobId} returns the same record for a published role. When the job is no longer published Jobsoid answers HTTP 204 with no body — that, along with 404 and 410, is the provider's own removal signal and should not be retried as a transport error.

Step 4: Re-read a single job and handle the 204 response
def fetch_job(session: requests.Session, tenant: str, job_id: str) -> dict | None:
    response = session.get(
        f"https://{tenant}.jobsoid.com/api/v1/jobs/{job_id}",
        headers={"Accept": "application/json"},
        timeout=30,
    )
    # 204 is Jobsoid's canonical "no longer published" answer, alongside 404/410.
    if response.status_code in (204, 404, 410):
        return None
    response.raise_for_status()

    row = response.json()
    if str(row.get("id")) != str(job_id):
        raise RuntimeError("Jobsoid detail API contradicted the requested job id")
    return map_row(row, tenant)

for listing in listings[:3]:
    job = fetch_job(session, "winterberry", listing["id"])
    print(job["title"] if job else f"{listing['id']} no longer published")

Attribute a tenantless portal link

Shared links use portal.jobsoid.com/j/{jobId}/{slug} and name no employer. Fetch that page and read the tenantDomainSlug that Jobsoid embeds beside the job object in its own route payload; accept it only when the embedded job id equals the one you requested, and leave the link unattributed otherwise.

Step 5: Attribute a tenantless portal link
PROOF = re.compile(
    r'"job"\s*:\s*\{\s*"id"\s*:\s*([1-9][0-9]*)'
    r'[\s\S]{0,200000}?"tenantDomainSlug"\s*:\s*"([a-z0-9][a-z0-9-]{0,61})"',
    re.IGNORECASE,
)

def resolve_tenant(session: requests.Session, job_id: str) -> str | None:
    response = session.get(
        f"https://portal.jobsoid.com/j/{job_id}",
        headers={"Accept": "text/html,application/xhtml+xml"},
        timeout=30,
    )
    if not response.ok:
        return None
    match = PROOF.search(response.text)
    # Jobsoid renders an explicit "Job Not Found" surface with no tenant at all.
    if not match or match.group(1) != str(job_id):
        return None
    return match.group(2).lower()

print(resolve_tenant(session, "131929"))
Common issues
highA portal.jobsoid.com link names no employer
The tenantless /j/{jobId} portal route carries a job id only. Fetch the page and take tenantDomainSlug from Jobsoid's own embedded route payload, requiring the embedded job id to match; a stale link renders an explicit 'Job Not Found' surface with no tenant and must stay unattributed.
highHTTP 204 is treated as a transport error
The per-job resource answers a no-longer-published job with 204 and an empty body rather than a 404. Classify 204 alongside 404 and 410 as removal evidence, otherwise closed roles keep being retried and never expire.
mediumA custom front-end domain is mistaken for the tenant
Employers can point a domain of their own at a Jobsoid board, and applyUrl may reflect it. Keep {tenant}.jobsoid.com as the canonical board and store the provider's HTTPS applyUrl separately rather than deriving identity from the branded host.
lowThe board is assumed to be paginated
The tenant collection is a single unpaginated array, so adding page or limit parameters gains nothing and risks truncation. Request /api/v1/jobs once and treat an empty array as an authoritative empty board.
Best practices
  1. 1Use the bare {tenant}.jobsoid.com host as the canonical board, whatever domain fronts it
  2. 2Exclude the reserved portal, www, api and portal-api labels when reading the tenant
  3. 3Request /api/v1/jobs once — the collection is complete and unpaginated
  4. 4Rebuild listing URLs from tenant, id and slug so identity survives a slug change
  5. 5Keep the provider's applyUrl only when it is HTTPS, and fall back to /apply/{jobId}
  6. 6Treat 204, 404 and 410 on the per-job resource as removal, not as errors to retry
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=jobsoid" \
  -H "X-Api-Key: YOUR_KEY"
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.

Ready to integrate

Access Jobsoid
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.

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