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.
| Base URL | The restUrl returned by REST login, e.g. https://rest{swimlane}.bullhornstaffing.com/rest-services/{corpToken}/ |
|---|---|
| Auth | OAuth 2.0 authorization code, then /rest-services/login
for a BhRestToken |
| Access | Credentials by support ticket; not included in the ATS Growth edition |
| Format | JSON |
| Pagination | start and count |
| Rate limits | 1,500 requests/minute, 50 sessions, 100,000 calls/month per client ID (unless agreed otherwise) |
| Webhooks | None: event subscriptions you poll, 100 events per call |
| Resume files | fileAttachments list, base64 download from file/Candidate/{id}/{fileId} |
| Docs | bullhorn.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_idandclient_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 backThe 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
requestIdso 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
breakPoll 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
- Bullhorn: Getting Started with REST
- Bullhorn: REST API reference
- Bullhorn: REST API entity reference
- Bullhorn: REST API event subscriptions
- Bullhorn knowledge base: API usage limits, versioning and backward compatibility
- HireLayer: Bullhorn integration
- HireLayer API documentation: Parse a resume (V3)
- HireLayer API documentation: Errors and retries
Louis Desclous
Published on · Reading time: 6 minutes
