ViRecruit / viDesktop Jobs API.

Extract vacancies from ViRecruit self-apply portals — the ASP.NET boards used by most large law firms — and reconcile every snapshot against the board's own positions counter.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on ViRecruit / viDesktop.

Data fields

  • Full Job Descriptions
  • Board Positions Counter
  • Modern & Legacy Layouts
  • GUID-Scoped Boards
  • Vendor & Custom Hosts
  • Inline Listing Descriptions

Use cases

  1. 01Legal Industry Job Boards
  2. 02Professional Services Hiring Feeds
  3. 03Law Firm Careers Monitoring
  4. 04Legacy ATS Migration Audits

Trusted by

  • BakerHostetler
  • Cozen O'Connor
  • Littler
DIY GUIDE

How to scrape ViRecruit / viDesktop.

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

API type
HTML
Difficulty
advanced
Rate limit
No published limit; ~250ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Recognise the two entry routes and keep the entry tag

Boards run under /viRecruitSelfApply/ on the vendor namespace *.viglobalcloud.com or on a firm-owned host. RecDefault.aspx is the modern board; ReApplicantEmail.aspx is a legacy email entry link that redirects into ReDefault.aspx with a different tag. Keep the entry tag as your durable seed.

Step 1: Recognise the two entry routes and keep the entry tag
from urllib.parse import urlparse, parse_qs

VENDOR_SUFFIX = ".viglobalcloud.com"
PAGES = {"recdefault.aspx", "redefault.aspx", "reapplicantemail.aspx", "rejobview.aspx"}

def parse_virecruit(url: str) -> dict:
    parsed = urlparse(url)
    path = parsed.path
    lowered = path.lower()
    if "/virecruitselfapply/" not in lowered:
        raise ValueError("not a ViRecruit self-apply route")

    page = lowered.rsplit("/", 1)[-1]
    if page not in PAGES:
        raise ValueError(f"unrecognised ViRecruit page {page}")

    # Some firms run a nested prefix, e.g. /viDesktopEx/viRecruitSelfApply/.
    prefix = path[: lowered.index("/virecruitselfapply/")].strip("/")
    query = {k.lower(): v[0] for k, v in parse_qs(parsed.query).items()}

    return {
        "host": parsed.netloc.lower(),
        "vendor_hosted": parsed.netloc.lower().endswith(VENDOR_SUFFIX),
        "prefix": prefix,
        "page": page,
        "tag": query.get("tag"),          # keep this even after a redirect
        "filter_job_id": query.get("filterjobid"),
        "job_id": query.get("jobid"),
    }

print(parse_virecruit("https://videsktop.littler.com/viRecruitSelfApply/RecDefault.aspx"
                      "?Tag=e742d0d0-59b7-46f6-a5cd-68c8e5b09b72&FilterJobID=432"))

Follow the legacy redirect without losing identity

This is the trap worth recording: a legacy ReApplicantEmail entry link redirects into ReDefault.aspx carrying a completely different tag. Follow the redirect to read inventory, but keep the entry tag as the firm's stable key so the company survives the hop.

Step 2: Follow the legacy redirect without losing identity
import requests

session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"

def open_board(entry_url: str) -> dict:
    entry = parse_virecruit(entry_url)
    resp = session.get(entry_url, timeout=30, allow_redirects=True)
    resp.raise_for_status()

    landed = parse_virecruit(resp.url)
    if landed["host"] != entry["host"]:
        raise RuntimeError("board redirected off-host — fail closed")
    if landed["page"] not in ("recdefault.aspx", "redefault.aspx"):
        raise RuntimeError("entry link did not land on a board page")

    return {
        "seed_tag": entry["tag"],       # durable identity
        "board_tag": landed["tag"],     # request-time tag; may differ
        "host": entry["host"],
        "prefix": entry["prefix"],
        "layout": "modern" if landed["page"] == "recdefault.aspx" else "legacy",
        "html": resp.text,
        "url": resp.url,
    }

board = open_board("https://cozencareers.viglobalcloud.com/viRecruitSelfApply"
                   "/ReApplicantEmail.aspx?Tag=734c1474-fb1c-4d68-aa1f-5535c19f4983")
print(board["layout"], board["seed_tag"], "->", board["board_tag"])

Reconcile the parsed rows against the positions counter

The board publishes its own positions counter. Only accept a snapshot when the number of rows you parsed equals that counter — a partially rendered WebForms page otherwise looks exactly like a board that shrank overnight.

Step 3: Reconcile the parsed rows against the positions counter
from bs4 import BeautifulSoup

def parse_board(board: dict) -> list[dict]:
    soup = BeautifulSoup(board["html"], "html.parser")

    # Every real board is a WebForms page; no ViewState means no board.
    if soup.select_one("form#Form1 input[name='__VIEWSTATE']") is None:
        raise RuntimeError("response is not a ViRecruit WebForms board")

    counter = soup.select_one("#contentPlaceHolder_spanPositionsCounter")
    if counter is None or not counter.get_text(strip=True).isdigit():
        raise RuntimeError("board omitted its positions counter")
    advertised = int(counter.get_text(strip=True))

    rows = []
    if board["layout"] == "modern":
        for row in soup.select("[data-vi-filter], .position-row"):
            heading = row.select_one("h4")
            if not heading:
                continue
            body = row.select_one("section.description [data-vi-filter='detail'], section.description")
            rows.append({
                "title": heading.get_text(strip=True),
                "description_html": body.decode_contents() if body else None,
            })
    else:
        for anchor in soup.select("a[href*='ReJobView.aspx']"):
            identity = parse_virecruit(anchor["href"] if anchor["href"].startswith("http")
                                       else f"https://{board['host']}{anchor['href']}")
            rows.append({"job_id": identity["job_id"], "title": anchor.get_text(strip=True)})

    if len(rows) != advertised:
        raise RuntimeError(f"parsed {len(rows)} rows but the board advertises {advertised}")
    return rows

listings = parse_board(board)
print(f"{len(listings)} positions")

Fetch legacy details from ReJobView

Modern boards carry the full description inline, so no second request is needed. Legacy boards only list titles; fetch each job from ReJobView.aspx with the board tag and the numeric job id, and read the title and description from their stable element IDs.

Step 4: Fetch legacy details from ReJobView
import time

def job_url(board: dict, job_id: str) -> str:
    prefix = f"/{board['prefix']}" if board["prefix"] else ""
    return (f"https://{board['host']}{prefix}/viRecruitSelfApply/ReJobView.aspx"
            f"?Tag={board['board_tag']}&JobID={job_id}")

def hydrate(board: dict, job_id: str) -> dict | None:
    url = job_url(board, job_id)
    resp = session.get(url, timeout=30)
    if resp.status_code == 404:
        return None            # the only removal evidence ViRecruit gives
    resp.raise_for_status()

    soup = BeautifulSoup(resp.text, "html.parser")
    if soup.select_one("form#Form1 input[name='__VIEWSTATE']") is None:
        raise RuntimeError("detail response is not a ViRecruit page")

    description = soup.select_one("#contentPlaceHolder_labelJobDescription")
    return {
        "job_id": job_id,
        "title": (soup.select_one("#page-title h3") or soup).get_text(strip=True),
        "description_html": description.decode_contents() if description else None,
        "listing_url": url,
        "apply_url": url,
    }

for row in listings[:3]:
    if row.get("job_id"):
        print(bool(hydrate(board, row["job_id"])))
        time.sleep(0.25)

Fail closed on unproved hosts and postback-only boards

A URL that merely looks like a ViRecruit route must not mint a firm. Require the vendor namespace or a host you have explicitly audited, reject external redirects, and treat a board whose rows are postback-only — with no addressable job id — as unsupported rather than empty.

Step 5: Fail closed on unproved hosts and postback-only boards
# Firm-owned hosts and their nested prefixes cannot be derived from the host
# alone, so pin the exact triples you have audited.
AUDITED_HOSTS = {
    "videsktop.littler.com": "",
    "portal.velaw.com": "viDesktopEx",
}

def is_supported(url: str) -> bool:
    try:
        route = parse_virecruit(url)
    except ValueError:
        return False
    if route["vendor_hosted"]:
        return True
    return AUDITED_HOSTS.get(route["host"]) == route["prefix"]

def board_state(board: dict) -> str:
    soup = BeautifulSoup(board["html"], "html.parser")
    if soup.select_one("a[href*='ReJobView.aspx']") or soup.select_one("section.description"):
        return "listable"
    if soup.select_one("#contentPlaceHolder_panelPositionsList") is None:
        return "unsupported"     # postback-only board, no addressable job ids
    return "empty"               # proved-empty board, an authoritative snapshot

print(is_supported("https://careers.example.com/viRecruitSelfApply/RecDefault.aspx?Tag=abc"))
print(board_state(board))
Common issues
criticalWhy does the tag in the URL change after the page loads?
A legacy ReApplicantEmail entry link redirects into ReDefault.aspx with a completely different tag. Keep the entry tag as the firm's durable identity and use the landed tag only to build request URLs — swapping them makes the company identity change on every crawl.
highWhy did a firm's job count suddenly drop?
A partially rendered WebForms page looks identical to a shrinking board. Compare your parsed row count against the board's own positions counter and fail the run on a mismatch, rather than publishing a truncated snapshot that expires live jobs.
highCan I accept any host serving /viRecruitSelfApply/?
No. Accept the vendor namespace *.viglobalcloud.com automatically, and pin firm-owned hosts explicitly — some run a nested path prefix that cannot be derived from the hostname. An unproved host that merely matches the route shape must fail closed.
mediumWhy do some boards list jobs with no clickable link?
A few deployments render rows as postbacks only, with no addressable job id. Those boards cannot be scraped honestly — classify them as unsupported rather than reporting an empty board, which would expire every job the firm still has open.
mediumWhen is a ViRecruit job actually removed?
Removal evidence is narrow: only a 404 on the canonical legacy detail URL counts. A proved-empty legacy board is a valid authoritative empty snapshot, but a board that simply failed to load is neither empty nor removed and must be retried.
Best practices
  1. 1Keep the entry tag as the firm's identity and follow the redirect only for inventory
  2. 2Require the board's positions counter to equal your parsed row count
  3. 3Read modern descriptions inline; only legacy boards need a ReJobView request
  4. 4Pin firm-owned hosts and their path prefixes explicitly instead of inferring them
  5. 5Classify postback-only boards as unsupported, never as empty
  6. 6Accept removal only from a 404 on the canonical legacy detail URL
Or skip the complexity

One endpoint. All ViRecruit / viDesktop jobs. No scraping, no sessions, no maintenance.

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