All platforms

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.

Get API access
WeRecruit
Live
<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 WeRecruit.

Data fields
  • Translated Job Titles
  • Contract & Employment Type
  • City, Region & Postal Code
  • Department & Schedule
  • Publication & Closing Dates
  • Full Job Descriptions
Use cases
  1. 01French Job Market Tracking
  2. 02Staffing & Interim Sourcing
  3. 03SMB Hiring Signals
  4. 04Multilingual Job Aggregation
DIY GUIDE

How to scrape WeRecruit.

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

HTMLintermediateNo published limit; pace ~400ms between requests, ~3 concurrent detail fetchesNo auth

Fetch the tenant board page

Every WeRecruit board lives at careers.werecruit.io/{locale}/{tenant}. There is no public JSON API — the offers are server-rendered into the HTML, so start by downloading the board page.

Step 1: Fetch the tenant board page
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.text

Carve out the window.allOffers JSON array

The board embeds every offer as one JSON array assigned to window.allOffers. Walk the brackets while tracking string state — a plain regex to the first ] breaks because other inline scripts contain unrelated brackets.

Step 2: Carve out the window.allOffers JSON array
import 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 None

Map each offer to structured fields

Offers use PascalCase keys. Key each job on the mutable Slug (the stable production id) and keep the UUID only as a candidate. Note Address_State is actually the ISO country code, not a US-style state.

Step 3: Map each offer to structured fields
import 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")

Pull full descriptions from the detail page

Fetch each offer's Url and read the JSON-LD JobPosting for the title, description, posting date and employment type. Its strings carry HTML entities (e.g. Île-de-France), so unescape them.

Step 4: Pull full descriptions from the detail page
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 {}

Guard slug collisions and pace requests

A live board can carry two distinct UUIDs under one slug. Keep the first, flag the collision, and never silently merge two postings. Add a short delay between detail fetches to stay polite on shared tenant boards.

Step 5: Guard slug collisions and pace requests
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 delay
Common issues
highwindow.allOffers block is missing or a regex grabs the wrong closing bracket

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

highTwo different UUIDs share the same slug on one board

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.

mediumDetail-page JSON-LD JobPosting is temporarily absent

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.

mediumAddress_State is mistaken for a US state

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.

lowAccented characters arrive as HTML entities

JSON-LD strings embed entities like &#xCE;le-de-France or Evol&#x27;Emploi. Run every JSON-LD string through html.unescape before storing or the text ends up garbled.

Best practices
  1. 1Key jobs on the mutable Slug; keep the UUID only as a candidate id until a migration is approved.
  2. 2Balance brackets with string-state tracking when carving out window.allOffers — never stop at the first ].
  3. 3Run html.unescape on every JSON-LD string to fix accented French text.
  4. 4Treat Address_State as an ISO country code, not a US state.
  5. 5Prefer the listing's TypeTranslated for contract type; JSON-LD employmentType only carries hours.
  6. 6Pace detail fetches (~400ms, ~3 concurrent) to stay polite on shared tenant boards.
Or skip the complexity

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

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

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.

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