All platforms

CVMail Jobs API.

Pull full vacancy feeds from UK law firms and graduate legal recruiters running hosted CVMail boards. Every listing and detail page renders as server-side HTML at a predictable main.cfm URL, so a plain GET-and-parse loop captures titles, practice areas, salaries and closing dates — no login or headless browser.

Get API access
CVMail
Live
<3haverage discovery time
1hrefresh interval
Companies using CVMail
Bevan BrittanRPCTravers Smith
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 CVMail.

Data fields
  • Full Job Descriptions
  • Practice Area & Department
  • Contract Type & Salary
  • Location & Closing Dates
  • Job Reference Codes
  • Native Apply URLs
Use cases
  1. 01Legal Sector Hiring Feeds
  2. 02Graduate & Trainee Vacancy Tracking
  3. 03Law Firm Careers Aggregation
  4. 04Practice-Area Talent Monitoring
Trusted by
Bevan BrittanRPCTravers Smith
DIY GUIDE

How to scrape CVMail.

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

HTMLintermediateNo official limit; the native scraper leaves ~200ms between requests (~5 req/sec) and caps concurrent detail fetches at 3No auth

Resolve the board host and tenant

CVMail boards live on a vendor host (*.cvmailuk.com or *.cvmail.net) at the path /{tenant}/main.cfm. The first path segment is the durable tenant key. Validate the host and shape before trusting a board — a bare main.cfm on some other domain is not CVMail.

Step 1: Resolve the board host and tenant
import requests
from urllib.parse import urlparse

# e.g. https://fsr.cvmailuk.com/rpc/main.cfm?page=jobBoard&fo=1
board_url = "https://fsr.cvmailuk.com/rpc/main.cfm?page=jobBoard&fo=1"
parts = urlparse(board_url)

host = parts.netloc.lower()
segments = [s for s in parts.path.split("/") if s]
assert host.endswith((".cvmailuk.com", ".cvmail.net")), "not a CVMail host"
assert len(segments) == 2 and segments[1].lower() == "main.cfm", "not a board URL"

tenant = segments[0].lower()   # "rpc"
print(tenant)

Fetch and parse the job-board table

Request the board URL with an HTML Accept header and parse the server-rendered tables. A job table is any <table> that contains a.jobMoreDetailCaptionStyle detail links; header cells are td.jbTableHeaderCaptionStyle and each row's values are td.jbTableTextStyle, so zip them into a label/value map.

Step 2: Fetch and parse the job-board table
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse, parse_qs

HEADERS = {"Accept": "text/html"}

def job_id_from(url):
    q = parse_qs(urlparse(url).query)
    if q.get("page", [""])[0] != "jobSpecific":
        return None
    jid = q.get("jobId", [""])[0]
    # jobId must be a clean alphanumeric/_/- token to be a valid key
    return jid if jid and jid.replace("-", "").replace("_", "").isalnum() else None

def parse_listings(board_url):
    html = requests.get(board_url, headers=HEADERS, timeout=15).text
    soup = BeautifulSoup(html, "html.parser")
    jobs, seen = [], set()
    for table in soup.select("table"):
        if not table.select("a.jobMoreDetailCaptionStyle[href]"):
            continue
        headers = [h.get_text(" ", strip=True)
                   for h in table.select("td.jbTableHeaderCaptionStyle")]
        for row in table.select("tr"):
            a = row.select_one("a.jobMoreDetailCaptionStyle[href]")
            if not a:
                continue
            detail_url = urljoin(board_url, a["href"])
            jid = job_id_from(detail_url)
            if not jid or jid in seen:
                continue
            seen.add(jid)
            values = [c.get_text(" ", strip=True) for c in row.select("td.jbTableTextStyle")]
            fields = {h: v for h, v in zip(headers, values) if h and v}
            jobs.append({
                "external_id": jid,
                "title": a.get_text(" ", strip=True),
                "detail_url": detail_url,
                "location": fields.get("Location"),
                "closing_date": fields.get("Closing date") or fields.get("Close date"),
                "department": fields.get("Department or practice area") or fields.get("Department"),
                "salary": fields.get("Salary"),
                "reference": fields.get("Reference"),
            })
    return jobs

Key each job on the native jobId — never rcd

Detail links look like main.cfm?page=jobSpecific&jobId={id}&rcd={record}. The jobId query value is the only durable posting key; rcd is volatile board/record state and must be kept as record_id metadata only, never used as the job key.

Step 3: Key each job on the native jobId — never rcd
def job_identity(detail_url):
    q = parse_qs(urlparse(detail_url).query)
    if q.get("page", [""])[0] != "jobSpecific":
        return None
    jid = q.get("jobId", [""])[0]
    if not jid:
        return None
    return {"external_id": jid, "record_id": q.get("rcd", [None])[0]}

print(job_identity(
    "https://fsr.cvmailuk.com/rpc/main.cfm?page=jobSpecific&jobId=78306&rcd=1176178"))
# {'external_id': '78306', 'record_id': '1176178'}

Fetch the detail page for description and fields

Detail pages are label/value cell pairs: td.jobFieldStyle labels alongside td.jobValueStyle values. Read the title from the 'Job Title' field (falling back to og:title) and the description from the 'Description' label. Themes render Description either in the same row or one row down in a nested table, so try the row first, then the enclosing table.

Step 4: Fetch the detail page for description and fields
def value_for(label_el):
    # value cell is usually in the same row as its label...
    row = label_el.find_parent("tr")
    if row:
        v = row.select_one("td.jobValueStyle")
        if v:
            return v
    # ...but Description often sits one row down inside a nested table
    table = label_el.find_parent("table")
    return table.select_one("td.jobValueStyle") if table else None

def fetch_detail(detail_url):
    resp = requests.get(detail_url, headers=HEADERS, timeout=15)
    if resp.status_code in (404, 410):
        return None  # CVMail returns 404/410 once a posting is pulled
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    fields, description = {}, None
    for label in soup.select("td.jobFieldStyle"):
        key = label.get_text(" ", strip=True)
        val = value_for(label)
        if not key or val is None:
            continue
        if key.lower() == "description":
            description = val.decode_contents().strip()
        else:
            text = val.get_text(" ", strip=True)
            if text:
                fields[key] = text

    title = fields.get("Job Title")
    if not title:
        og = soup.select_one('meta[property="og:title"]')
        title = og["content"].strip() if og and og.get("content") else None

    apply_a = next((a for a in soup.select("a.jobSpecificActionPaneItemStyle[href]")
                    if a.get_text(strip=True).lower() == "apply"), None)
    title_el = soup.select_one("title")
    return {
        "title": title,
        "description": description,
        "location": fields.get("Location"),
        "salary": fields.get("Salary"),
        "contract_type": fields.get("Contract type"),
        "reference": fields.get("Reference"),
        "closing_date": fields.get("Closing date") or fields.get("Close date"),
        "apply_url": urljoin(detail_url, apply_a["href"]) if apply_a else detail_url,
        "company": title_el.get_text(strip=True) if title_el else None,
    }

Terminate the snapshot safely

A board renders in one request and does not advertise a safe continuation cursor. Treat an explicit 'no current vacancies' message as genuinely empty, but treat a paging Next control (form[name='paging'] submit/button, or an a[href*='start_row='] link) as evidence of more pages, so mark the snapshot incomplete rather than assuming you captured everything.

Step 5: Terminate the snapshot safely
def snapshot_status(board_html):
    soup = BeautifulSoup(board_html, "html.parser")
    text = soup.get_text(" ", strip=True).lower()

    if any(m in text for m in
           ("no current vacancies", "no vacancies found", "0 vacancies")):
        return "empty"   # complete: the board is genuinely empty

    has_next = (
        soup.select_one("form[name='paging'] input[type='submit'][value*='Next' i]")
        or soup.select_one("form[name='paging'] button[value*='Next' i]")
        or soup.select_one("a[href*='start_row=']")
    )
    # Complete only when a job table rendered AND no Next control is present.
    return "incomplete" if has_next is not None else "complete"
Common issues
highUsing rcd as the job key produces unstable, duplicated IDs

rcd is volatile board/record state that changes with form context. Parse the jobId query value from page=jobSpecific links as the durable key and keep rcd only as record_id metadata.

highDescription comes back empty on some tenants

Themes render the Description value either in the same row as its td.jobFieldStyle label or one row down inside a nested table. Look for a td.jobValueStyle in the label's row first, then fall back to the first td.jobValueStyle in the enclosing table.

mediumOnly the first page of vacancies is captured

The board renders a single HTML page with no safe continuation cursor. Detect a Next control (form[name='paging'] submit/button or an a[href*='start_row='] link) and flag the snapshot incomplete instead of assuming the first page is the whole board.

mediumAn empty board is misread as a parse failure

Distinguish a genuinely empty board (page text says 'no current vacancies' / 'no vacancies found' / '0 vacancies') from a missing job table, which signals a layout change or wrong URL that needs re-inspection.

lowDetail requests return 404 or 410

CVMail returns 404/410 once a posting is withdrawn. Treat those status codes as a removal signal for that job rather than a hard scrape failure, and drop the listing from the active set.

Best practices
  1. 1Validate the host ends with cvmailuk.com or cvmail.net and the path is /{tenant}/main.cfm before trusting a board.
  2. 2Key every job on the native jobId; store rcd only as record_id metadata.
  3. 3Parse Description defensively: same-row value cell first, then the enclosing table's value cell.
  4. 4Treat a paging Next control or start_row link as 'more pages' and mark the snapshot incomplete.
  5. 5Separate an explicit 'no current vacancies' board from a missing table (a real layout change).
  6. 6Self-throttle to ~5 requests/second and cap concurrent detail fetches at ~3, matching the native scraper's pacing.
Or skip the complexity

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

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

Access CVMail
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