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.
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.
- Full Job Descriptions
- Practice Area & Department
- Contract Type & Salary
- Location & Closing Dates
- Job Reference Codes
- Native Apply URLs
- 01Legal Sector Hiring Feeds
- 02Graduate & Trainee Vacancy Tracking
- 03Law Firm Careers Aggregation
- 04Practice-Area Talent Monitoring
How to scrape CVMail.
Step-by-step guide to extracting jobs from CVMail-powered career pages—endpoints, authentication, and working code.
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)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 jobsdef 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'}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,
}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"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.
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.
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.
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.
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.
- 1Validate the host ends with cvmailuk.com or cvmail.net and the path is /{tenant}/main.cfm before trusting a board.
- 2Key every job on the native jobId; store rcd only as record_id metadata.
- 3Parse Description defensively: same-row value cell first, then the enclosing table's value cell.
- 4Treat a paging Next control or start_row link as 'more pages' and mark the snapshot incomplete.
- 5Separate an explicit 'no current vacancies' board from a missing table (a real layout change).
- 6Self-throttle to ~5 requests/second and cap concurrent detail fetches at ~3, matching the native scraper's pacing.
One endpoint. All CVMail jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=cvmail" \
-H "X-Api-Key: YOUR_KEY" 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.