- 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.
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.
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
- 01SMB & Mid-Market Job Aggregation
- 02Multi-Country Employer Feeds
- 03Careers Page Extraction
- 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.
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"))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")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")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")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
Best practices
- 1Use the bare {tenant}.jobsoid.com host as the canonical board, whatever domain fronts it
- 2Exclude the reserved portal, www, api and portal-api labels when reading the tenant
- 3Request /api/v1/jobs once — the collection is complete and unpaginated
- 4Rebuild listing URLs from tenant, id and slug so identity survives a slug change
- 5Keep the provider's applyUrl only when it is HTTPS, and fall back to /apply/{jobId}
- 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 accesscURL
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
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