Skip to content

Bullhorn API Guide: OAuth, REST Login, Candidates and Resume Files

Use the Bullhorn REST API: get credentials, run OAuth 2.0, log in for a BhRestToken, search candidates, download resumes and poll event subscriptions.

Published · 6 minutes read

Cover reading “Bullhorn API guide for developers” with a candidate icon and a code icon

The Bullhorn API is a REST API for the Bullhorn staffing ATS and CRM that reads and writes records such as Candidate, JobOrder and Placement as JSON. You authenticate with OAuth 2.0, then trade the access token for a BhRestToken session and a restUrl base URL. From there you can search candidates, download resume files and poll event subscriptions for changes.

Bullhorn API key facts
Base URLThe restUrl returned by REST login, e.g. https://rest{swimlane}.bullhornstaffing.com/rest-services/{corpToken}/
AuthOAuth 2.0 authorization code, then /rest-services/login for a BhRestToken
AccessCredentials by support ticket; not included in the ATS Growth edition
FormatJSON
Paginationstart and count
Rate limits1,500 requests/minute, 50 sessions, 100,000 calls/month per client ID (unless agreed otherwise)
WebhooksNone: event subscriptions you poll, 100 events per call
Resume filesfileAttachments list, base64 download from file/Candidate/{id}/{fileId}
Docsbullhorn.github.io, checked November 2, 2026

The code below is Python 3.10+ with requests, based on the documented endpoints. If you only need the ready-made flow (score each submission and add a note), the Bullhorn integration page has it.

1. Get API credentials

Bullhorn has no self-serve key screen. Honestly, this is the slowest step: open a ticket in the Bullhorn Resource Center and ask for:

  • a client_id and client_secret;
  • a registered redirect_uri (authorization can only redirect there);
  • refresh tokens enabled on the key, since they are off by default.

Create a dedicated API user too, so a recruiter leaving doesn't break the sync. Rate limits are counted per client ID, so every service sharing one shares the same budget.

2. Authenticate and get a BhRestToken

Call GET https://rest.bullhornstaffing.com/rest-services/loginInfo?username=… first: it tells you wich data center hosts your account, and the OAuth and login hosts follow from it. Then it's three calls: an authorization code, an access token (valid 10 minutes), and the REST login that returns BhRestToken and restUrl.

# bullhorn_auth.py - Python 3.10+, pip install requests
import os
import secrets
from urllib.parse import parse_qs, urlparse

import requests

DC = os.environ["BULLHORN_DATA_CENTER"]  # the {value} of auth-{value}, from loginInfo
AUTH_URL = f"https://auth-{DC}.bullhornstaffing.com/oauth"
LOGIN_URL = f"https://rest-{DC}.bullhornstaffing.com/rest-services/login"
CLIENT = {"client_id": os.environ["BULLHORN_CLIENT_ID"],
          "client_secret": os.environ["BULLHORN_CLIENT_SECRET"]}
REDIRECT_URI = os.environ["BULLHORN_REDIRECT_URI"]  # registered with Bullhorn

def get_auth_code() -> str:
    """Headless login with the API user; a form body keeps the password out of URLs."""
    state = secrets.token_urlsafe(16)
    response = requests.post(f"{AUTH_URL}/authorize", data={
        "client_id": CLIENT["client_id"], "response_type": "code", "action": "Login",
        "username": os.environ["BULLHORN_USERNAME"], "password": os.environ["BULLHORN_PASSWORD"],
        "redirect_uri": REDIRECT_URI, "state": state,
    }, allow_redirects=False, timeout=30)  # a 307 here means the wrong data center
    query = parse_qs(urlparse(response.headers.get("Location", "")).query)
    if query.get("state") != [state] or "code" not in query:
        raise RuntimeError(f"No authorization code (HTTP {response.status_code})")
    return query["code"][0]

def get_tokens(**grant) -> dict:
    """Access token (10 minutes) + refresh token, from a code or a refresh token."""
    response = requests.post(f"{AUTH_URL}/token", params={**CLIENT, **grant}, timeout=30)
    response.raise_for_status()
    return response.json()

def rest_login(access_token: str) -> tuple[str, str]:
    response = requests.post(LOGIN_URL, params={"version": "*", "access_token": access_token}, timeout=30)
    response.raise_for_status()
    return response.json()["BhRestToken"], response.json()["restUrl"]

# First run: get_tokens(grant_type="authorization_code", code=get_auth_code(), redirect_uri=REDIRECT_URI)

The first login of a new API user can stop at Bullhorn's terms of service. Run the flow once in a browser with that user, accept, and the headless version works from then on.

Calling the wrong data center gets you a 307 redirect to the right one, which the loginInfo hosts avoid. On the login call, version=* asks for the latest API version.

3. Reuse the session and search candidates

Bullhorn strictly limits login rates, so keep one session and log in agian only on 401. Each refresh returns a new refresh token and kills the old one: store it before anything else, and keep the refresh in one worker. On 429, Bullhorn says to wait one second and retry.

search/Candidate takes a Lucene query such as lastName:Smith AND isDeleted:0; without fields you only get IDs back. For entities outside the search index, such as CandidateWorkHistory, use query/{entity} with a JPQL-style where clause instead.

# bullhorn_client.py
import os
import time
from pathlib import Path

import requests
from bullhorn_auth import get_tokens, rest_login

TOKEN_FILE = Path(os.environ.get("BULLHORN_TOKEN_FILE", "bullhorn-refresh-token"))

class Bullhorn:
    """One REST session for every call, renewed on 401."""
    def __init__(self) -> None:
        self.http = requests.Session()
        self.login()

    def login(self) -> None:
        tokens = get_tokens(grant_type="refresh_token", refresh_token=TOKEN_FILE.read_text().strip())
        TOKEN_FILE.write_text(tokens["refresh_token"])  # the old one stops working now
        self.token, self.rest_url = rest_login(tokens["access_token"])

    def request(self, method: str, path: str, **params) -> dict:
        for _ in range(30):
            r = self.http.request(method, self.rest_url + path,
                                  params={**params, "BhRestToken": self.token}, timeout=60)
            if r.status_code == 429:
                time.sleep(1)  # Bullhorn: wait one second, then retry
            elif r.status_code == 401:
                self.login()  # session expired
            else:
                r.raise_for_status()
                return r.json() if r.content else {}
        raise RuntimeError(f"Gave up on {path}")

    def search(self, entity: str, query: str, fields: str, count: int = 100):
        start = 0
        while page := self.request("GET", f"search/{entity}", query=query, fields=fields,
                                   sort="id", count=count, start=start).get("data"):
            yield from page
            start += len(page)  # advance by what actually came back

The reference sets no maximum count, so the loop advances start by the records actually returned and stops on an empty page. Queries over 7,500 characters, such as a long list of IDs, go as a POST with query in the JSON body. On query calls, add totalOnly=true when you only need the count.

4. Download the resume and parse it

entity/Candidate/{id}/fileAttachments lists a candidate's files, and file/Candidate/{id}/{fileId} returns one as base64. The type labels come from your own Bullhorn setup, so check which one your recruiters use for resumes.

Bullhorn's own resume/parseToCandidate fills a candidate inside Bullhorn. To get the resume as JSON you can use anywhere, send the file to HireLayer CV Extract: one multipart POST, 1 credit per successful call, 50 free credits a month. Using the Bullhorn ID as application_id keeps the two systems linked.

# sync.py
import base64
import os

import requests

from bullhorn_client import Bullhorn

PARSER_URL = "https://hirelayer.co/api/v3/parser"


def latest_resume(bh: Bullhorn, candidate_id: int) -> tuple[str, bytes] | None:
    files = bh.request("GET", f"entity/Candidate/{candidate_id}/fileAttachments",
                       fields="id,name,type,dateAdded,isDeleted").get("data", [])
    resumes = [f for f in files if f["type"] == "Resume" and not f["isDeleted"]]
    if not resumes:
        return None
    file_id = max(resumes, key=lambda f: f["dateAdded"])["id"]
    file = bh.request("GET", f"file/Candidate/{candidate_id}/{file_id}")["File"]
    return file["name"], base64.b64decode(file["fileContent"])


def parse_resume(name: str, content: bytes, application_id: str) -> dict:
    response = requests.post(
        PARSER_URL,
        headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
        files={"file": (name, content)},
        data={"application_id": application_id},
        timeout=150,  # about 35 s per resume, longer for scans
    )
    response.raise_for_status()  # 401 key, 403 credits, 413 size, 422 not a resume
    return response.json()


if __name__ == "__main__":
    bh = Bullhorn()
    for candidate in bh.search("Candidate", "isDeleted:0", "id,firstName,lastName"):
        if resume := latest_resume(bh, candidate["id"]):
            parsed = parse_resume(*resume, application_id=f"bullhorn-{candidate['id']}")
            print(candidate["id"], parsed["info_candidate"]["job_title"], len(parsed["skills"]))

A trimmed response looks like this:

{
  "status": "success",
  "info_resume": { "application_id": "bullhorn-4821", "language": "EN" },
  "info_candidate": { "full_name": "Alex Morgan", "email": "alex.morgan@example.com",
                      "phone_number": "+33612345678", "job_title": "Senior Software Engineer" },
  "work_experiences": [{ "company_name": "Northstar Labs", "job_title": "Senior Software Engineer",
                         "start_date": "2022-03-01", "end_date": null }],
  "educations": [{ "degree_title": "Master of Science in Computer Science", "school_name": "École Polytechnique" }],
  "skills": [{ "skill_title": "React", "skill_type": "Software skill", "status": "normalized" }]
}

Expect about 35 seconds per resume, longer for scans, hence the 150-second timeout. Only 502, 503 and 504 are worth retrying (honor Retry-After); 401, 403, 413 and 422 such as DOCUMENT_NOT_A_RESUME are final, as the errors guide explains. The parse is also stored under its application_id, so you can score it against jobs later without sending the file again.

5. Keep in sync with event subscriptions

Bullhorn doesn't push webhooks. You create an event subscription once and read it on a schedule; each event gives the entity, its ID, the event type and, for updates, the names of the changed properties (not the new values).

  • Reading consumes. Events are removed once read; keep each response's requestId so you can fetch a batch again after a crash.
  • Unread events expire. Bullhorn's two official sources disagree (7 or 30 days, 15 or 50 subscriptions), so design for the stricter figures.
from bullhorn_client import Bullhorn
from sync import latest_resume, parse_resume

SUBSCRIPTION = "hirelayer-candidates"
bh = Bullhorn()

# Run once: new and updated candidates go to this subscription.
bh.request("PUT", f"event/subscription/{SUBSCRIPTION}",
           type="entity", names="Candidate", eventTypes="INSERTED,UPDATED")

# Run on a schedule. Reading removes the events from the subscription.
while True:
    batch = bh.request("GET", f"event/subscription/{SUBSCRIPTION}", maxEvents=100)
    events = batch.get("events", [])
    for event in events:
        if event["entityEventType"] == "INSERTED":
            candidate_id = event["entityId"]
            if resume := latest_resume(bh, candidate_id):
                parse_resume(*resume, application_id=f"bullhorn-{candidate_id}")
    if len(events) < 100:  # 100 is the maximum per call
        break

Poll at a steady cadence (Bullhorn's examples are hourly or every 30 minutes), and for UPDATED events check updatedProperties before fetching anything. A DELETE on the subscription URL removes it, so test subscriptions don't eat into the limit.

6. Write results back

Bullhorn creates entities with PUT and updates them with POST. Work history and education are seperate entities (CandidateWorkHistory, CandidateEducation), and notes are Note records. In practice, write only to empty fields and never overwrite what a recruiter typed.

The Bullhorn integration maps every CV Extract field to its Bullhorn entity, and the ATS integration guide covers duplicates and review queues.

Frequently asked questions

Does Bullhorn have a public API?

Yes, a documented REST API, but access isn't self-serve: you request OAuth credentials by support ticket, and the ATS Growth edition doesn't include it.

How long does a Bullhorn access token last?

The OAuth access token lasts 10 minutes and is only used to log in. The BhRestToken session has its own expiry (ping returns sessionExpires); on a 401, refresh and log in again.

What is the Bullhorn API rate limit?

Up to 1,500 requests per minute, 50 active sessions and 100,000 calls per month per client ID, unless agreed otherwise. On 429, wait one second and retry.

Does Bullhorn support webhooks?

Not as push notifications. You poll event subscriptions, up to 100 events per call.

See what a parsed Bullhorn resume looks like

Upload a resume to the live demo to see every CV Extract field before you write the mapping.

Sources and further reading

  1. Bullhorn: Getting Started with REST
  2. Bullhorn: REST API reference
  3. Bullhorn: REST API entity reference
  4. Bullhorn: REST API event subscriptions
  5. Bullhorn knowledge base: API usage limits, versioning and backward compatibility
  6. HireLayer: Bullhorn integration
  7. HireLayer API documentation: Parse a resume (V3)
  8. HireLayer API documentation: Errors and retries

Louis Desclous

Published on · Reading time: 6 minutes