WeRecruit Jobs API.
Pull structured job offers from French SMB and staffing career boards on careers.werecruit.io, decoded straight from the JSON embedded in each board page.
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 WeRecruit.
- Translated Job Titles
- Contract & Employment Type
- City, Region & Postal Code
- Department & Schedule
- Publication & Closing Dates
- Full Job Descriptions
- 01French Job Market Tracking
- 02Staffing & Interim Sourcing
- 03SMB Hiring Signals
- 04Multilingual Job Aggregation
How to scrape WeRecruit.
Step-by-step guide to extracting jobs from WeRecruit-powered career pages—endpoints, authentication, and working code.
import requests
locale, tenant = "fr", "walter-learning"
board_url = f"https://careers.werecruit.io/{locale}/{tenant}"
resp = requests.get(board_url, timeout=15)
if resp.status_code == 404:
raise LookupError(f"tenant '{tenant}' not found")
resp.raise_for_status()
html = resp.textimport re
_START = re.compile(r"window\.allOffers\s*=\s*")
def extract_all_offers_json(html: str) -> str | None:
m = _START.search(html)
if not m or m.end() >= len(html) or html[m.end()] != "[":
return None
start = m.end()
depth = 0
in_string = escape = False
for i in range(start, len(html)):
c = html[i]
if escape:
escape = False
elif in_string:
if c == "\\":
escape = True
elif c == '"':
in_string = False
elif c == '"':
in_string = True
elif c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
return html[start:i + 1]
return Noneimport json
offers = json.loads(extract_all_offers_json(html))
jobs = []
for o in offers:
# Require all three: a malformed offer must not silently drop half the alias pair.
if not (o.get("Url") and o.get("Slug") and o.get("Id")):
continue
title = o.get("TitleTranslated") or next(iter((o.get("Title") or {}).values()), None)
company = o.get("Subsidiary_Name") or o.get("Company_Name") or o.get("Holding_Name")
jobs.append({
"external_id": o["Slug"].strip(), # stable key
"native_id": o["Id"], # UUID (candidate only)
"url": o["Url"].strip(),
"title": title,
"company": company,
"employment_type": o.get("TypeTranslated"),
"schedule": o.get("TimeTranslated"),
"department": o.get("Address_Department"),
"city": o.get("Address_City"),
"region": o.get("Address_Region"),
"postal_code": o.get("Address_PostalCode"),
"country": o.get("Address_State"), # ISO country code
"published_at": o.get("PublicationStartDate"),
"closes_at": o.get("EndDate"),
})
print(f"parsed {len(jobs)} offers")import html as ihtml
from bs4 import BeautifulSoup
def fetch_details(job_url: str) -> dict:
resp = requests.get(job_url, timeout=15)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except (ValueError, TypeError):
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
desc = BeautifulSoup(node.get("description", ""), "html.parser").get_text(" ", strip=True)
return {
"title": ihtml.unescape(node.get("title", "")),
"description": desc,
"posted_at": node.get("datePosted"),
"employment_type": node.get("employmentType"),
}
# Fallback: some pages briefly render the offer before the JSON-LD is present.
article = soup.select_one("article.single-offre")
if article:
h1 = article.select_one("h1")
body = article.select_one(".description.rich-text")
return {
"title": h1.get_text(strip=True) if h1 else None,
"description": body.get_text(" ", strip=True) if body else None,
}
return {}import time
seen = {} # slug -> native UUID
for job in jobs:
prior = seen.get(job["external_id"])
if prior is not None:
if prior != job["native_id"]:
print(f"identity collision on slug {job['external_id']} — review before merging")
continue
seen[job["external_id"]] = job["native_id"]
for job in [j for j in jobs if seen.get(j["external_id"]) == j["native_id"]]:
job.update(fetch_details(job["url"]))
time.sleep(0.4) # mirror the scraper's ~400ms crawl delayLocate window.allOffers = and walk from the opening [ to its matching ] while tracking string/escape state. Other inline <script> blocks on the page contain unrelated brackets, so a naive regex returns truncated or invalid JSON.
Key jobs on the Slug and treat the Id (UUID) as a candidate only. When a second UUID appears under a known slug, keep the first and flag the collision instead of overwriting — merging drops a live posting.
The offer can render before its JSON-LD is published. Fall back to the visible HTML — read article.single-offre h1 and .description.rich-text — after confirming the canonical link and form UUID match the listing.
The upstream Address_State field holds an ISO country code (e.g. "FR"), not a region. Map it to country and use Address_Region for the sub-national area.
JSON-LD strings embed entities like Île-de-France or Evol'Emploi. Run every JSON-LD string through html.unescape before storing or the text ends up garbled.
- 1Key jobs on the mutable Slug; keep the UUID only as a candidate id until a migration is approved.
- 2Balance brackets with string-state tracking when carving out window.allOffers — never stop at the first ].
- 3Run html.unescape on every JSON-LD string to fix accented French text.
- 4Treat Address_State as an ISO country code, not a US state.
- 5Prefer the listing's TypeTranslated for contract type; JSON-LD employmentType only carries hours.
- 6Pace detail fetches (~400ms, ~3 concurrent) to stay polite on shared tenant boards.
One endpoint. All WeRecruit jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=werecruit" \
-H "X-Api-Key: YOUR_KEY" Access WeRecruit
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.