Mark Dovgalyuk

Bullhorn's REST API Part 1: 3-legged OAuth and rotating refresh tokens

Bullhorn's API uses 3-legged OAuth, built for human-in-the-loop apps. Backend integrations have to design around that mismatch, plus the single-use refresh tokens Bullhorn issues and the failure modes that come with them.

Black stylized bull head silhouette on an off-white background.
· 5 min read

Bullhorn is a CRM for staffing and recruiting companies. Think Salesforce, but purpose-built around the recruiting workflow (candidates, jobs, submissions, placements, prescreen notes).

This post covers its OAuth 2.0 implementation and the edge cases that implementation creates for backend integrations. Endpoints referenced below are documented in Bullhorn’s REST API reference.

3-legged vs. 2-legged OAuth

Bullhorn’s API uses 3-legged OAuth: a user in the loop explicitly authorizes access, the way signing in with Google shows a consent screen before an app gets permission. The three legs are the user who owns the data, the client app, and the authorization server.

Bullhorn’s records are organized around user accounts (a recruiter’s contacts, pipeline, calendar), so tying every session to a user identity has a clear logic to it, though the docs never state the reasoning.

Diagram of 3-legged OAuth. The client app sends the user to /authorize, the user logs in and consents at the auth server, and the auth server returns an authorization code that the client exchanges for access and refresh tokens before calling the API.

3-legged OAuth: a human approves the session before any tokens are issued (typical 3LO flow).

2-legged OAuth drops the user. The client presents its own credentials, a client ID and secret, for an access token, then calls the API directly.

Diagram of 2-legged OAuth. The client app sends its client ID and secret to the auth server, receives an access token, and calls the API directly. A dashed box notes the user is not in the loop.

2-legged OAuth: the client authenticates as itself. No user, no consent screen (typical 2LO flow).

2-legged OAuth (2LO) is a server-to-server pattern, and fits a backend integration more natively than 3-legged OAuth (3LO) does.

Many CRMs are built around 3LO, but some (Salesforce for example) also offer the OAuth 2.0 client credentials flow for backend integrations. Bullhorn doesn’t offer this, however.

Much of the complexity comes from this mismatch: a server-to-server integration (typically 2LO territory) running on a 3LO flow. Without a client-credentials grant, the thing that keeps the integration alive between sessions is a single-use refresh token. And storing, rotating, and recovering that token becomes your problem.

Bullhorn’s auth flow

  1. Look up your data center URLs: Send a GET request to /rest-services/loginInfo with your username.
  2. Get an authorization code: Send a GET request to /oauth/authorize with client_id, response_type=code, username, password, and action=Login. The code returns as a query parameter on the redirect URL.
  3. Exchange the code for tokens: Send a POST request to /oauth/token with grant_type=authorization_code, the code from step 2, your client_id, and client_secret. This returns a short-lived access token (10 minutes) and a long-lived refresh token.
  4. Exchange the access token for a session: Send a POST request to /rest-services/login?version=*&access_token=.... You receive a BhRestToken session token and the base restUrl for your actual API requests.
  5. Call the API: Include the BhRestToken in your headers and target the restUrl for all subsequent requests.
  6. Handle session expiration: When the session expires, either re-authenticate from step 2 or use the active refresh_token to retrieve a new access token.

This differs from standard 3LO: the access token never touches the API. It’s spent once at /login for a BhRestToken, and that token authenticates every actual request.

Step 6 carries the design weight. Bullhorn declines to promise a session lifetime at all: /login takes an optional ttl in minutes, but the docs warn “Never assume that a REST session will not expire” and recommend treating a 401 as the signal rather than tracking a clock.

There are two renewal paths:

  1. Re-run the full auth flow. Pass username, password, and action=Login to /authorize and start over. Simple, and it needs nothing persisted beyond the service-account credentials. But Bullhorn throttles login rates, so frequent re-authentication risks getting blocked.
  2. Use the refresh token. This dodges the throttling, but the tokens are single-use: “a refresh token expires after it is used once”. The old token dies the instant the new one is issued, which opens a gap between receiving the new token and persisting it. If a network error eats the response or the write fails, the new token is lost and the old one is already retired. The next session loads a dead token, and the integration is stuck.

Option 1 holds up at low frequency. Past that, the refresh token is the only path that avoids the login throttle, which makes its failure modes unavoidable.

Handling invalid_grant

Prevention. The constraint the API imposes is narrow: a stored token has exactly one valid successor, so two clients refreshing at once will produce a winner and a loser, and the loser must not overwrite the winner. One way to enforce that is to take a short lease before calling the refresh endpoint, then write the new token back with a compare-and-swap so a stale write fails instead of clobbering a live token. The mechanics are application-specific; the constraint isn’t.

Recovery. If a crash or network error lands mid-rotation, the next refresh loads a dead token and gets invalid_grant back. That response is ambiguous on its own: it means the token was spent, but not by whom. Checking whether another process already stored a newer token distinguishes the two cases. If none exists, the only way back is full reauthorization, which is worth serializing so several clients don’t hit the throttled login endpoint at once.

Takeaways

Bullhorn’s 3LO flow takes extra care to use reliably server-to-server. Complexity came from designing around persistence, failure recovery, and the operational edge cases. OAuth 2.0 is a wide protocol and it’s important to treat each provider’s implementation as its own system, with its own logic and failure modes.

In Part 2, I’ll dig deeper into several of Bullhorn’s API query engines and their interesting quirks, where JPQL-based queries and Lucene-backed indexing disagree, and what that means for ordering and pagination.

More writing

→ All posts
Minimalist black silhouette of three stacked layers, centered on an off-white background.

· 5 min read

Bullhorn's REST API Part 2: JPQL, Lucene, and pagination design

Bullhorn splits bulk reads across a relational JPQL engine and a Lucene index. The two order results differently, and that difference decides which pagination strategy works on each.