- highOne company's jobs are split across several boards
- companyGK alone does not identify a board. Employers publish multiple portalGK values with intentionally different inventories, so scraping a single portal silently misses the rest. Key every snapshot on the companyGK and portalGK pair and enumerate the portals you care about explicitly.
- highJobs disappear from the feed and are wrongly marked closed
- The RSS document is the whole inventory, so a transient truncation looks exactly like a batch of closures. Before retiring a requisition, fetch its jb.do page and require either a 404/410 or the 'currently closed to new submittals' marker; anything else is inconclusive.
- mediumTitles and descriptions render as entity soup
- Feed items are HTML-encoded twice over: the description arrives as escaped markup inside the XML text node. Run html.unescape on both title and description after XML parsing, otherwise consumers see literal <p> sequences instead of formatted content.
- lowFeed links use the j.brt.mv short domain
- Some items link through BrightMove's own shortener rather than portal.brightmove.com. Accept both hosts when reading the reqGK, companyGK and portalGK out of a link, then rebuild the canonical portal.brightmove.com/jb.do URL yourself so stored URLs stay stable.
BrightMove Jobs API.
BrightMove is a staffing and recruiting ATS whose public boards live on portal.brightmove.com. Each portal publishes a complete RSS feed carrying every open job with its full description — no auth, no pagination.
What's in every response.
Data fields, real-world applications, and the companies already running on BrightMove.
Data fields
- Full Job Descriptions
- Complete Per-Portal Feed
- Native Requisition IDs
- Publish Dates
- Multiple Portals Per Company
- Direct Apply URLs
Use cases
- 01Staffing Agency Feeds
- 02Recruiting Marketplace Ingestion
- 03Job Board Syndication
- 04ATS Data Pipelines
How to scrape BrightMove.
Step-by-step guide to extracting jobs from BrightMove-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
HOST = "portal.brightmove.com"
def parse_portal(url: str) -> dict | None:
parsed = urlparse(url)
if parsed.netloc.lower() != HOST:
return None
if parsed.path.lower() not in ("/jb.do", "/companyportal.do"):
return None
query = {k.lower(): v[0] for k, v in parse_qs(parsed.query).items()}
company, portal = query.get("companygk"), query.get("portalgk")
if not (company and portal and company.isdigit() and portal.isdigit()):
return None
job_id = query.get("reqgk")
if parsed.path.lower() == "/jb.do" and not (job_id or "").isdigit():
return None
return {"company_gk": company, "portal_gk": portal, "job_id": job_id}
print(parse_portal(
"https://portal.brightmove.com/jb.do?reqGK=27780002&companyGK=18890&portalGK=1815"))import requests
from xml.etree import ElementTree
def fetch_feed(session, company_gk: str, portal_gk: str) -> ElementTree.Element:
url = f"https://{HOST}/JobRSS.do?companyGK={company_gk}&portalGK={portal_gk}"
resp = session.get(url, headers={"Accept": "application/rss+xml"}, timeout=30)
resp.raise_for_status()
root = ElementTree.fromstring(resp.content)
channel = root.find("channel")
if channel is None:
raise RuntimeError("BrightMove response was not an RSS document")
generator = (channel.findtext("generator") or "").strip()
if generator != "BrightMove ATS":
raise RuntimeError("feed did not identify itself as BrightMove ATS")
link = parse_portal((channel.findtext("link") or "").strip())
if not link or link["company_gk"] != company_gk or link["portal_gk"] != portal_gk:
raise RuntimeError("feed channel contradicted its company/portal identity")
return channel
session = requests.Session()
channel = fetch_feed(session, "18890", "1815")
print(channel.findtext("title"))import html
def map_items(channel, company_gk: str, portal_gk: str) -> list[dict]:
company_name = (channel.findtext("title") or "").strip()
jobs, seen = [], set()
for item in channel.findall("item"):
link = parse_portal((item.findtext("link") or "").strip())
title = html.unescape((item.findtext("title") or "").strip())
description = html.unescape((item.findtext("description") or "").strip())
# Reject rows that do not prove the portal identity we requested.
if not link or link["company_gk"] != company_gk:
continue
if link["portal_gk"] != portal_gk or not link["job_id"]:
continue
if not title or not description or link["job_id"] in seen:
continue
seen.add(link["job_id"])
jobs.append({
"id": link["job_id"],
"title": title,
"description_html": description,
"company": company_name,
"posted_at": item.findtext("pubDate"),
"guid": item.findtext("guid"),
"url": (f"https://{HOST}/jb.do?reqGK={link['job_id']}"
f"&companyGK={company_gk}&portalGK={portal_gk}"),
})
return jobs
jobs = map_items(channel, "18890", "1815")
print(f"{len(jobs)} open jobs")def is_closed(session, company_gk: str, portal_gk: str, job_id: str) -> bool | None:
url = (f"https://{HOST}/jb.do?reqGK={job_id}"
f"&companyGK={company_gk}&portalGK={portal_gk}")
resp = session.get(url, headers={"Accept": "text/html"}, timeout=30)
if resp.status_code in (404, 410):
return True
if not resp.ok:
return None # inconclusive — retry later
# BrightMove's provider-owned marker for a requisition that stopped accepting applications.
if "currently closed to new submittals" in resp.text.lower():
return True
return None
print(is_closed(session, "18890", "1815", "27780002"))- 1Treat the companyGK and portalGK pair as the board key, never companyGK alone
- 2Require the BrightMove ATS generator and a matching channel link before parsing items
- 3Take descriptions straight from the feed — every item already carries the full body
- 4Unescape HTML entities in title and description after XML parsing
- 5Rebuild canonical jb.do URLs from the three keys instead of storing feed links
- 6Confirm a closure on the canonical page before retiring a requisition
One endpoint. All BrightMove jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=brightmove" \
-H "X-Api-Key: YOUR_KEY"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.
Access BrightMove
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.