Bullhorn API Integration: A Developer's Guide
Most Bullhorn API integration projects fail in month three, not week one. The happy path — authenticate, read a candidate, write a note — takes an afternoon. What breaks later is session expiry under load, duplicate candidates nobody notices until a client complains, and a nightly sync that silently stops returning rows.
Everything below comes from Bullhorn's own documentation. Where Bullhorn publishes no number, we say so rather than guess — much of what circulates about this API online is folklore.
TL;DR
- Authentication is two stages: OAuth 2.0 gets an
access_token, which you exchange at/rest-services/loginfor aBhRestTokenand a tenant-specificrestUrl. - The
access_tokenlasts 10 minutes. Refresh tokens are single-use. - Published limits: 1,500 requests/minute, 50 active sessions, 100,000 calls/month — scoped to your OAuth Client ID, not per tenant.
search(Lucene) covers nine entities; everything else usesquery(JPQL), with different parameter names.- There is no deduplication API and no webhooks. Both are your problem.
- Deletes are soft deletes. Filter
isDeletedon every read.
How do I get a Bullhorn API key?
You cannot self-serve credentials, and picking the wrong route costs weeks.
Customers building an internal integration get keys, per Bullhorn's developer FAQ, "by creating a support ticket via the Bullhorn Resource Center" — the right path for a warehouse feed, a submission portal, or a timesheet bridge on your own tenant.
Products other agencies will install need partner keys, and the same FAQ is blunt: "Partner keys are only available directly from Bullhorn" — via partners@bullhorn.com or the Marketplace form.
Developer program vs Marketplace partner program
The practical distinction is one client ID per product, not per customer: Bullhorn's developer program guide states each application "must use a unique partner API key," so customers can authorise and revoke each integration independently.
The Marketplace route adds a gate. Bullhorn's platform partner program page states that "all integrations must undergo a Technical Services-assisted validation engagement to ensure they meet our security, data privacy, and technical performance standards," costing $1,000 to $4,000 depending on complexity. That sits on top of a $5,000 annual platform fee to join the program. Budget for both at the start of a commercial build, not the end — a recurring $5,000 changes the economics of a small integration considerably.
(First Bridge is not a Bullhorn Marketplace partner and holds no Bullhorn certification — we build against the public REST API on our clients' credentials. Confirm current requirements with Bullhorn directly.)
The authentication handshake
Three calls before your first real request, and the first matters more than teams realise.
Step 1 — discover the datacenter. Bullhorn is sharded across swimlanes; hardcode a hostname and you break the day a client is migrated.
GET https://rest.bullhornstaffing.com/rest-services/loginInfo?username={API_Username}
This returns the tenant's oauthUrl and restUrl. Bullhorn warns that calling the wrong datacenter returns a 307 redirect that "your code must be written to handle." Many HTTP clients drop the body or Authorization header on a cross-host 307, so resolve the host up front.
Step 2 — OAuth 2.0. Authorisation code grant, with a shortcut that skips the interactive login page:
GET https://auth-{swimlane}.bullhornstaffing.com/oauth/authorize
?client_id={client_id}&response_type=code&action=Login
&username={username}&password={password}&state={state}
POST https://auth-{swimlane}.bullhornstaffing.com/oauth/token
?grant_type=authorization_code&code={auth_code}
&client_id={client_id}&client_secret={client_secret}
Step 3 — the REST login, the step people miss: the OAuth token is not the API credential.
POST https://rest-{swimlane}.bullhornstaffing.com/rest-services/login
?version=*&access_token={access_token}
{
"BhRestToken": "1234_5945926_32b73003-3b3a-4ebf-9a87-5894201b0ac3",
"restUrl": "https://rest{swimlane}.bullhornstaffing.com/rest-services/{corpToken}/"
}
Every later call goes to that returned restUrl — which embeds a tenant-specific corpToken — passing BhRestToken as a query parameter, header, or cookie.
Token refresh and session expiry
Three facts from Bullhorn's getting-started guide that determine your session design:
- The access token is valid for 10 minutes.
- Refresh tokens rotate and are single-use — a new one is returned with every access token, and the old one "does expire when a new access token and refresh token are generated." Persist the new token atomically. Two workers refreshing concurrently from the same stored value invalidate each other, locking you out until someone re-authenticates by hand.
- Do not log in per request. Bullhorn states it plainly: "you must NOT perform a fresh REST API login before every API request." Login rates are limited, and a client that authenticates per call will be blocked.
The login call accepts an optional ttl in minutes, but Bullhorn publishes neither a default nor a maximum. So detect session expiry rather than predicting it: GET /ping returns a sessionExpires timestamp, an invalid token returns 401, a missing one 412. Wrap your client so a 401 triggers one re-login and a single retry, then gives up rather than looping.
The entity model
Six entities carry almost every integration:
| Entity | Role |
|---|---|
Candidate |
The person. Has submissions and webResponses collections. |
ClientCorporation |
The client company. |
ClientContact |
A person at that company. |
JobOrder |
The open role. |
JobSubmission |
Joins a Candidate to a JobOrder. The core of the workflow. |
Placement |
Created when a submission is approved. One JobOrder, one Candidate. |
JobSubmission carries a subtlety. Per Bullhorn's entity reference, setting status to "New Lead" makes it a web response — an informal submission — rather than a formal one. Creating a web response sets dateWebResponse; promoting it sets dateAdded. Get this wrong and your submissions never appear in the pipeline reports the agency runs its desk on.
Custom field counts are not uniform: Candidate exposes customText1 through customText40, JobSubmission through customText25, others stop at 10. Read /meta/{entityType} rather than assuming.
Query vs search
Two read endpoints, two query languages, two sets of parameter names.
GET /search/{entity} takes Lucene syntax in query and sorts with sort. Exactly nine entities are searchable: Candidate, ClientContact, ClientCorporation, JobOrder, Lead, Note, Opportunity, Placement, Task. "Entity types not listed above use the query operation."
GET /query/{entity} takes JPQL in where and sorts with orderBy, with datetimes as UNIX milliseconds.
Both take fields, start, and count, and both require fields or layout. Two things to know:
- Use the POST variants for long filters. Bullhorn advises POST whenever the
whereorqueryvalue exceeds 7,500 characters. URL-length failures under a largeINclause are a classic intermittent bug. - Bullhorn publishes no maximum
countfor query or search. The "500" that circulates belongs to different endpoints (/department{Entity}s,/my{Entity}s);/optionscaps at 300. Do not build a pager around an undocumented ceiling — page conservatively and read the rows actually returned.
Counting is asymmetric too: /query has totalOnly, /search has no equivalent.
What are the Bullhorn API rate limits?
Bullhorn publishes concrete figures in its knowledge base, for all ATS editions except ATS Growth:
| Limit | Published value |
|---|---|
| Requests per minute | 1,500 |
| Active sessions | 50 |
| Monthly API calls | 100,000 "unless otherwise agreed upon with Bullhorn" |
| Active API subscriptions | 50 |
| TTL on unused subscriptions / un-retrieved events | 30 days |
The line that changes architecture: "Rate limits are scoped to your OAuth Client ID, not to individual tenants or sessions." Ship to twenty agencies on one client ID and all twenty share one budget. Throttle centrally.
Throttling returns HTTP 429 with two distinct messages — "Too Many Requests, API rate limit exceeded" and "Server API Capacity Exceeded". Bullhorn's guidance is to wait one second and retry, noting "most 429 errors clear within 10 retries" and, usefully, that calls returning 429 do not count against your usage limits. Add jitter; synchronised retries across workers recreate the problem you are recovering from.
The monthly ceiling quietly kills projects: a five-minute polling loop across six entity types burns roughly half of it doing nothing. Which is why the subscription API exists.
How do I sync candidates bidirectionally without duplicates?
Two facts missing from most integration plans.
There are no webhooks. Bullhorn's change feed is pull-based — you register a subscription and poll it:
PUT {corpToken}/event/subscription/{subscriptionId}
?type=entity&names=Candidate,JobSubmission
&eventTypes=INSERTED,UPDATED,DELETED
GET {corpToken}/event/subscription/{subscriptionId}?maxEvents=100
Each event carries entityName, entityId, entityEventType, and — for updates — updatedProperties, so you can skip changes touching fields you do not mirror. Events replay via the optional requestId parameter, which makes recovery after an outage tractable. Watch the 30-day TTL: an un-polled subscription dies silently, and the first symptom is a sync that has done nothing for weeks.
There is no deduplication API. The word "duplicate" does not appear in Bullhorn's REST API reference. PUT /entity/Candidate creates a record unconditionally — nothing stops you writing the same person in three times.
externalID exists on Candidate (50 chars), ClientContact and ClientCorporation (30), and JobOrder (100), and it is the right place for your system's identifier — but Bullhorn documents no uniqueness constraint and no upsert-by-externalID behaviour. It is a plain nullable string. Enforce the key yourself:
- Query first:
where=externalID='CRM-40192' AND isDeleted=false. - Row returned?
POST /entity/Candidate/{id}to update. None?PUTto create. - Persist the returned Bullhorn
idimmediately, before any other work. - Serialise writes per candidate — two workers handling the same person concurrently both see "no match" and both create.
For loop prevention, compare dateLastModified against your last write and skip events your own integration caused. Without it, a bidirectional sync ping-pongs records between systems until it hits the rate limit.
Three more production traps
Soft deletes. Deleting sets isDeleted: true; the row stays. Bullhorn's docs guarantee exclusion only for nested to-many associations — top-level query and search behaviour is undocumented, and Bullhorn's own examples filter explicitly (search/Candidate?query=isDeleted:0). Add the filter to every read. Reporting integrations that omit it inflate placement counts — the kind of bug found in a board pack.
Field-level permissions. Entitlements are per entity type (/entitlements/{entityType} returns CREATE, READ, UPDATE, DELETE and department/corporate variants), and an action without the entitlement fails outright. The subtler behaviour: for nested to-many associations the user cannot read, Bullhorn returns only predefined fields — id, first name, last name — and the rest come back as null, not as an error. A restricted service account produces a sync that appears to work while silently writing empty fields. Test with the exact account that will run in production.
Mass updates. POST /massUpdate/{entityType} supports eleven entity types and accepts up to 10,000 ids per call, far cheaper than looping. But GET /massUpdate/{entityType} returns each updatable property alongside the entitlement it requires (status needs "Mass Update Job Status", isDeleted needs "Mass Delete Job"). Check entitlements first, or a 10,000-record job fails on the first call.
Build it in-house or commission it?
One-directional and read-only is usually fine in-house. Bidirectional sync is where the cost sits — dedupe logic, loop prevention, token rotation, and the reconciliation job that catches what the event feed missed. That is the same calculation we work through in build vs buy: ATS for a staffing agency.
We build the layer agencies bolt onto the ATS they already own: client submission portals, timesheet-to-invoice and margin reporting, and API integrations against Bullhorn and other recruitment platforms. See custom recruitment software and software development, or contract staffing if you would rather add an engineer to your own team.
Send us the shape of your sync — entities, direction, volume — at success@firstbridgeconsulting.com or via the contact form, and we will tell you where the hard parts are before you commit to a design.