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.

Get API access

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

  1. 01Staffing Agency Feeds
  2. 02Recruiting Marketplace Ingestion
  3. 03Job Board Syndication
  4. 04ATS Data Pipelines
DIY GUIDE

How to scrape BrightMove.

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

API type
Hybrid
Difficulty
beginner
Rate limit
No published limit; one feed request per portal, then ~100ms between any follow-up page checks
Authentication
No auth

Parse companyGK and portalGK

BrightMove identity is a pair of numeric keys. companyGK is the employer and portalGK is one of that employer's public portals — a single company can run several portals with deliberately different inventories, so both keys are required to address a board.

Step 1: Parse companyGK and portalGK
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"))

Fetch the portal RSS feed

JobRSS.do is the complete inventory for one portal — every open job in a single document, with no pagination or cursor. Before trusting it, check that the channel names BrightMove ATS as its generator and that the channel link carries the same companyGK and portalGK you asked for.

Step 2: Fetch the portal RSS feed
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"))

Map every feed item

Each item is a complete job: the link carries reqGK, and the description holds the full posting body rather than a teaser. Both title and description arrive HTML-encoded, so unescape them, and rebuild the canonical job URL from the three keys rather than trusting the link verbatim.

Step 3: Map every feed item
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")

Confirm closures on the canonical page

Absence from the feed is not proof that a job closed — a feed hiccup looks identical. When a previously seen reqGK stops appearing, fetch its canonical jb.do page and look for BrightMove's own closed marker before retiring the record.

Step 4: Confirm closures on the canonical page
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"))
Common issues
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.
Best practices
  1. 1Treat the companyGK and portalGK pair as the board key, never companyGK alone
  2. 2Require the BrightMove ATS generator and a matching channel link before parsing items
  3. 3Take descriptions straight from the feed — every item already carries the full body
  4. 4Unescape HTML entities in title and description after XML parsing
  5. 5Rebuild canonical jb.do URLs from the three keys instead of storing feed links
  6. 6Confirm a closure on the canonical page before retiring a requisition
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=brightmove" \
  -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 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.

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