Bullhorn integration · Event subscriptions + REST API
Score Bullhorn candidates with HireLayer
Bullhorn parses resumes itself and is rolling out a parser built with Textkernel technology, a company Bullhorn owns. HireLayer is an independent complement that processes data in the EU: it scores each submission against the job order with an explanation per criterion, and returns normalized skills your recruiters can search.
- Trigger
- Event subscription: JobSubmission inserted
- Bullhorn API
- REST API, OAuth 2.0 and a BhRestToken session
- Write-back
- Note, custom fields, work history, skills
- Cost
- 2 credits per submission, plus 1 per job order
- 1Bullhorn logs the submissionBullhornA candidate is submitted to a job order, or applies from a job board as a web response. Bullhorn adds an INSERTED event for the JobSubmission to your subscription.
- 2Parse the resumeHireLayerYour service reads the events on a schedule, downloads the Resume attachment with /file/Candidate/{id}/{fileId} and sends it to HireLayer CV Extract.
- 3Score against the job orderHireLayerHireLayer Match compares the resume with criteria taken from the job order description and returns a score and an explanation per criterion.
- 4Write back to BullhornBullhornA note on the candidate linked to the job order, the score in a custom field of the submission, and skills or work history when the record lacks them.
Use cases
What staffing teams do with HireLayer and Bullhorn
Sort web responses
Job board applicants arrive as submissions with the status New Lead. A score on each one lets recruiters call the strongest maches first instead of working in arrival order.
Prepare client shortlists
Send the resume text of up to 10 candidates for a job order to HireLayer Rank and get them back in order, each with a score and a rationale to support the submittal.
Searchable skills
Match HireLayer’s normalized skills to your Bullhorn Skill list and add them to primarySkills, so candidates parsed years appart share the same skill names.
Backfill imported records
Candidates imported from an older system may have a resume attachment but no work history. Parse the attachment and create CandidateWorkHistory and CandidateEducation entries.
Setup
Connect Bullhorn to HireLayer
This is a service you run on a schedule, for example a serverless function or an n8n workflow. It needs Bullhorn REST API access, which Bullhorn does not include in ATS Growth (formerly Team Edition).
- 1
Request API credentials
Open a support ticket in the Bullhorn Resource Center to get an OAuth client ID, client secret and registered redirect URI. Use a dedicated API user whose entitlements cover candidates, submissions, files and notes.
- 2
Log in and keep the session
Call GET https://rest.bullhornstaffing.com/rest-services/loginInfo?username=… to find your data center, get an OAuth access token (valid 10 minutes), then POST /rest-services/login for a BhRestToken and your restUrl. Refresh the token instead of logging in before every call.
- 3
Subscribe to submissions
Create the subscription once with PUT /event/subscription/hirelayer?type=entity&names=JobSubmission&eventTypes=INSERTED, then read it on a schedule, for example every 30 minutes, with GET /event/subscription/hirelayer?maxEvents=100.
- 4
Extract criteria, parse, score and write back
Send each job order’s description to HireLayer Job Extract once and store the criteria. For each event, load the submission, download the resume, parse it, score it and create a note with PUT /entity/Note.
Field mapping
Where HireLayer results go in Bullhorn
Bullhorn creates entities with PUT and updates them with POST. Work history and education are separate entities linked to the candidate, and custom fields are set up per account in Field Mappings.
| HireLayer field | Bullhorn REST API | Note |
|---|---|---|
| score + summary + evaluated_criteria | PUT /entity/Note (personReference, commentingPerson, action, comments) | action must be in your commentActionList |
| score | JobSubmission customInt1–5 (POST /entity/JobSubmission/{id}) | For example score × 100 |
| work_experiences[] | PUT /entity/CandidateWorkHistory (companyName, title, startDate, endDate, comments) | title holds 50 characters |
| educations[] | PUT /entity/CandidateEducation (school, degree, startDate, endDate) | |
| skills[].skill_title | primarySkills (PUT /entity/Candidate/{id}/primarySkills/{skillIds}) | IDs from your Skill list, or skillSet as text |
| info_candidate.job_title | occupation (POST /entity/Candidate/{id}) |
Example
A polling job in TypeScript
A minimal Node.js job: it reads new JobSubmission events, downloads each candidate’s Resume attachment, parses and scores it, and adds a note linked to the job order. Add your own session refresh, storage for criteria and retries.
const HIRELAYER = 'https://hirelayer.co'
const headers = { 'X-API-Key': process.env.HIRELAYER_API_KEY! }
// restUrl and BhRestToken come from POST /rest-services/login.
// Reuse the session: do not log in again before every call.
export async function poll(restUrl: string, bhRestToken: string) {
const bh = (path: string, init?: RequestInit) =>
fetch(`${restUrl}${path}${path.includes('?') ? '&' : '?'}BhRestToken=${bhRestToken}`, init)
.then((r) => r.text())
.then((text) => (text ? JSON.parse(text) : null))
// Created once: PUT event/subscription/hirelayer?type=entity&names=JobSubmission&eventTypes=INSERTED
const batch = await bh('event/subscription/hirelayer?maxEvents=100')
for (const event of batch?.events ?? []) {
const { data: submission } = await bh(
`entity/JobSubmission/${event.entityId}?fields=id,candidate,jobOrder`
)
const candidateId = submission.candidate.id
const files = await bh(`entity/Candidate/${candidateId}/fileAttachments?fields=id,name,type`)
const resume = files.data.find((f: { type: string }) => f.type === 'Resume')
if (!resume) continue
// 1. Download (JSON with base64 content) and parse (about 35 s)
const { File } = await bh(`file/Candidate/${candidateId}/${resume.id}`)
const form = new FormData()
form.append('file', new Blob([Buffer.from(File.fileContent, 'base64')]), File.name)
form.append('application_id', String(submission.id))
const parsed = await fetch(`${HIRELAYER}/api/v3/parser`, {
method: 'POST', headers, body: form, signal: AbortSignal.timeout(150_000),
}).then((r) => r.json())
// 2. Score with the criteria stored for this job order
const criteria = await loadCriteria(submission.jobOrder.id)
const match = await fetch(`${HIRELAYER}/api/v1/matching/job-candidate`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
job_text: criteria.jobText,
candidate_text: parsed.info_resume.text,
matching_criteria: criteria.items,
}),
}).then((r) => r.json())
// 3. Note on the candidate, linked to the job order (PUT creates)
await bh('entity/Note', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
personReference: { id: candidateId },
commentingPerson: { id: Number(process.env.BULLHORN_USER_ID) },
jobOrder: { id: submission.jobOrder.id },
action: 'HireLayer score', // a value of your commentActionList
comments: `HireLayer score: ${Math.round(match.score * 100)}/100\n${match.summary}`,
}),
})
}
}Limits
Good to know
API access depends on your edition
ATS Growth (formerly Team Edition) has no REST API access, only Marketplace integrations. On other editions, Bullhorn allows up to 1,500 requests per minute and 50 concurrent sessions; on HTTP 429, wait one second and retry.
Events are pulled, not pushed
Bullhorn has no push webhooks. Events contain IDs only, and Bullhorn purges the ones nobody reads in time, so keep the schedule runing. The number of subscriptions per database is limited too.
Bullhorn already parses resumes
Its parser fills the candidate, work history and education, also through POST /resume/parseToCandidate, and its upgrade with Textkernel technology rolls out through 2026. Use HireLayer for job-specific scores and normalized skills, and write history only to records that lack it.
Not a Marketplace app
HireLayer is not listed in the Bullhorn Marketplace. Your service uses your own API credentials, so its calls count toward your API limits; usage tied to validated Bullhorn partners does not.
HireLayer APIs used on this page
FAQ
HireLayer and Bullhorn: questions
Does HireLayer integrate with Bullhorn?
Yes, through the Bullhorn REST API: your service reads new submissions from an event subscription, sends the resume to HireLayer, and writes the score back as a note and fields. HireLayer has no app in the Bullhorn Marketplace.
Bullhorn already parses resumes. Why add HireLayer?
Bullhorn’s parser fills the candidate record. HireLayer answers a different question: how well this candidate fits this job order, with a score from 0 to 1 and an explanation per criterion. It also returns normalized skills, and Bullhorn’s own parse stays as it is.
Can I screen Bullhorn candidates with AI?
Yes. HireLayer turns the job order into weighted criteria and scores each submission against them, with explanations written in French. The score helps recruiters decide whom to call first; it does not change submission statuses.
Does Bullhorn send webhooks?
No. Bullhorn uses event subscriptions that your service reads on a schedule, and each event holds IDs only. For no-code, Make lists community-built Bullhorn apps, not maintained by Make, which also need OAuth keys from Bullhorn support.
Where is candidate data processed?
HireLayer processes data in the EU. Resumes go from Bullhorn to HireLayer only through the service you run, and the parse request accepts a do_not_store_data option for strict retention rules.
Connect Bullhorn to HireLayer today
The Free plan includes 50 credits a month, no card required. One successful call is one credit, on every API.
