---
name: bullhorn-oauth-app
description: Establishes whether a Bullhorn OAuth2 credential can legitimately be obtained at all and, if so, obtains it — client ID, client secret, API username and API-user password, all issued by hand by Bullhorn Support or the Bullhorn Alliances team, never from a developer portal — with the per-customer data-centre swimlane that must be discovered at runtime, the two-step OAuth-token-then-BhRestToken session dance, single-use refresh tokens that must be explicitly enabled on the key, the entitlements model that replaces scopes, per-client-ID rate limits shared across every customer, and a safe credential handoff. Use when asked to get Bullhorn OAuth credentials, register a Bullhorn app, become a Bullhorn API Access or Marketplace partner, obtain a Bullhorn sandbox, or fix a Bullhorn auth error like a 307 redirect loop, `invalid_grant` on refresh, a 401 on every REST call after a successful token exchange, or fields that silently come back null. For any other vendor's developer portal, use that vendor's skill instead.
---

# Bullhorn API Credentials

**Read this first, because it may end the run: Bullhorn has no developer portal, and Bullhorn's published policy
forbids the exact shape of platform this skill is usually invoked for.** There is no console, no "create app" button,
no self-service registration of any kind. Every Bullhorn OAuth credential is created by a human at Bullhorn — Support
for a customer's own key, the Alliances team for a contracted integrator — and delivered by email through a support
case. And Bullhorn's own API Access FAQ answers the middleware question directly and in the negative:

> "Q: Can I leverage a third party to build my integration? A: **We do not permit third-party 'connector' or
> 'middleware' companies to build integrations with Bullhorn.** Integrators are required to leverage in-house
> developers to build a direct, platform-to-platform integration to the Bullhorn API."

and, on the obvious workaround of just collecting each customer's own key:

> "Q: Can I just get access to my customer's instance and build directly to them? A: We do not offer free access to
> anybody, and **customers will not be provided with their API Key if they intend to provide it to a third party
> vendor who is not contracted with Bullhorn.**"

The Fair Use Policy says the same thing from the legal side: a third-party service provider accessing the API on a
customer's behalf "is subject to all terms of this policy, including … registering with Bullhorn," access happens only
"at such customer's specific written instruction identifying the third party," and there must be "No Access on Behalf
of Others … without Bullhorn's explicit written permission."

So there are exactly two honest answers to "get me Bullhorn OAuth credentials for our multi-tenant platform":

1. **The platform contracts with Bullhorn** under the API Access Program — annual platform fee, no redlines on the
   agreement, a security assessment, and only then a sandbox (§3). Note that even a contracted integrator is told to
   build "a direct, platform-to-platform integration," so whether a unified-API layer qualifies is a **commercial
   conversation with Bullhorn, not a technical one**. Get it in writing before building.
2. **Each customer holds their own credential**, requested from Bullhorn Support under their own contract, with
   Bullhorn's knowledge that a named third party will use it (§4). This is the path most platforms are actually on,
   and the one the FAQ above constrains. It is workable only where the customer has told Bullhorn who you are.

Do not start a registration that cannot complete. Establish which of the two applies (§1) before anything technical.

Once you do hold credentials, four things cost real time, and three of them have nothing to do with registration: the
**per-customer data-centre swimlane** that makes the auth host itself different for every tenant and must be
discovered at runtime (§5) — the single most common Bullhorn integration failure; the **two-step token dance**, where
the OAuth access token is not an API credential at all but a ticket you trade for a short-lived REST session (§6);
**refresh tokens that are single-use and are not enabled on every key** (§6); and **the absence of scopes entirely**,
because Bullhorn's access control is the authorizing user's entitlements, which your consent screen cannot request
and your token response cannot report (§7).

## Inputs to collect before you start

Ask in one batch. Never invent.

| Input | Notes |
| --- | --- |
| **Which credential model** | Platform-level API Access contract, or per-customer keys? (§1) |
| **Contract status** | Is there a signed Bullhorn API Access Agreement? A sandbox? A named Alliances contact? (§3) |
| **For per-customer keys: who is the customer's authorized support contact** | Only they can open the ticket (§4) |
| **Owner's email for the key request** | Bullhorn sends the credentials to this address, and only this address (§4) |
| **Redirect URI(s)** | Every callback host, final, up front — changing them is another support ticket (§4, §5) |
| **Environment** | Sandbox and production are separate keys on separate swimlanes; request each separately (§4) |
| **Whether refresh tokens are required** | Must be explicitly requested — a key can be issued without them (§6) |
| **The customer's API username** | Needed at *authorize* time to find their data centre, not just at call time (§5) |
| **The Bullhorn edition / cluster** | Some editions have no API access at all; the cluster ID fixes the swimlane (§5, §8) |
| **Expected monthly call volume** | Limits are per client ID and shared across all your customers (§8) |

## Quick Start

1. Establish which **credential model** is legitimately available here, and whether the middleware prohibition ends
   the run (§1). Most wrong turns — and every wasted month — start here.
2. Confirm whether credentials already exist worth reusing; re-issuing orphans every existing authorization (§2).
3. If platform-level: contract through Bullhorn Alliances, pass the security assessment, get the sandbox (§3).
4. Request the key by support ticket, with the redirect URI and refresh-token requirement stated explicitly (§4).
5. Build **data-centre discovery** before you build anything else — the auth host is per customer (§5).
6. Implement the **two-step token dance** and the single-use refresh rotation (§6).
7. Stop looking for scopes. Pin down the authorizing user's **entitlements** instead (§7).
8. Check rate limits, call caps and edition gates before promising a launch date (§8).
9. Verify with a real authorize → callback → login → refresh → refresh-again round trip on a *second* swimlane (§9).
10. Hand the secret to a human, never to source control (§10).

## Platform state (verified 2026-09-20 — re-verify before trusting)

- **There is no Bullhorn developer portal.** Bullhorn's developer FAQ and Getting Started page both say the same
  thing: "Bullhorn customers can obtain OAuth keys for developing applications with the Bullhorn REST API by creating
  a support ticket via the Bullhorn Resource Center." Bullhorn's knowledge base restates it flatly for the one
  credential-request workflow it documents step by step: "These credentials are not self-service."
- **Partner keys come from a different door.** Bullhorn's FAQ: "Partner keys are only available directly from
  Bullhorn. If you are interested in becoming a partner, send an email message to `partners@bullhorn.com` or fill out
  the form available on the Bullhorn Marketplace."
- **Middleware/connector companies are prohibited** from building Bullhorn integrations per the API Access FAQ, and
  customers are told they will not receive an API key intended for an uncontracted third party. This is current as of
  this verification and is the single most important fact on this page.
- **The API Access Program is paid and non-negotiable.** Annual platform fee (amount **not published** — do not quote
  a figure), including "a sandbox, technical resources, and 200,000 API calls per month," overage charges beyond that,
  fee "must be paid in full at the beginning of each annual term," and "we do not accept redlines to the API Access
  Agreement." Onboarding order: Alliances sends the contract → Finance invoices → **security assessment** → sandbox
  access once the assessment passes and the invoice clears.
- **Listing is a separate paid engagement.** "Promotion of your integration is not allowed unless you decide to go
  through our validation process. This is a paid engagement with our Technical Services team." The platform-partner
  page adds that "All integrations must undergo a Technical Services-assisted validation engagement to ensure they
  meet our security, data privacy, and technical performance standards."
- **The published data-centre list is out of date.** `Data-Center-URLs` is bylined **April 9, 2018** and does not
  list an Australian lane at all, yet `auth-aus.bullhornstaffing.com` and `rest-aus.bullhornstaffing.com` both
  resolve in DNS today, and a live `loginInfo` response for an AU cluster returns `auth-aus`/`rest-aus` with
  `superClusterId: 66`. The page also lists APAC as CLS61/CLS62 on `auth-syd`, which does not cover every APAC
  cluster seen in the wild. **Treat the published list as documentation of the idea, never as a lookup table.**
- **`loginInfo` fails open, which is dangerous.** On 2026-09-20 a GET of
  `https://rest.bullhornstaffing.com/rest-services/loginInfo?username=<a username that does not exist>` returned
  **HTTP 200** with a complete, plausible response body pointing at a default US-West lane (`cls31`, `auth-west`,
  `rest-west`). It does **not** 404 on an unknown user. A typo in the API username therefore produces a confidently
  wrong data centre rather than an error.
- **Rate limits are scoped to the client ID, not the tenant.** Bullhorn's knowledge base: "Rate limits are scoped to
  your OAuth Client ID, not to individual tenants or sessions. This means all API calls made under the same OAuth
  Client ID share a single rate limit." For a platform with one shared client ID across many customers this is the
  defining capacity constraint (§8).
- **The Fair Use Policy was last bylined 17 December 2025** and now carries explicit AI/LLM clauses: no connecting the
  API "to any 3rd party AI or LLM tools for viewing or updating data without Bullhorn's explicit written permission,"
  and no export/import/update/delete "by any unauthorized solutions or models, like Model Context Protocols (i.e.
  MCPs), without Bullhorn's explicit written permission." If the platform exposes Bullhorn data to an AI feature,
  that is a **contract question to raise before shipping**, not a footnote.
- **No PKCE anywhere in Bullhorn's documented flow**, and no scope parameter. Neither appears in the OAuth overview
  or the Getting Started walkthrough. Absence of documentation is not proof of absence; it is proof you should not
  build on it.
- **Two API generations coexist.** Bullhorn documents both a REST API and a legacy SOAP ("web services") API, and
  recommends REST. SOAP keys are self-serve in the customer's own account (Tools → BH Connect → Web Services API);
  **REST OAuth keys are not**. A customer who says "I generated an API key myself" has almost certainly generated the
  wrong one.
- **Support-forum answers are not documentation.** `supportforums.bullhorn.com` was unreachable from this run; where
  it is reachable, treat it as community folklore and confirm anything load-bearing against a Bullhorn-owned page.

If the developer pages or the knowledge base do not look like this, stop and report what you actually see.

## 1. Which credential, and who is allowed to hold it

| Surface | Credential | Hosts | Who gets it |
| --- | --- | --- | --- |
| **REST API** (core ATS/CRM read/write) via **OAuth 2.0** | Client ID + secret, authorization code grant, plus an API username and a password set on that API user | authorize/token on `auth-{lane}.bullhornstaffing.com/oauth`; REST on `rest-{lane}.bullhornstaffing.com/rest-services` | Bullhorn Support, by ticket, for a contracted customer; Bullhorn Alliances for a contracted integrator |
| **REST API** as a contracted **integrator** | Same shape, issued under the API Access Program with a sandbox | Same | Companies with a signed API Access Agreement (§3) |
| **SOAP / "web services" API** (legacy) | API key generated by the customer's own admin in Tools → BH Connect → Web Services API | `api-{lane}.bullhornstaffing.com` | Any customer admin, self-serve — **not** a REST credential |

Consequences worth saying out loud before anyone starts work:

- **"Bullhorn API key" is ambiguous and the ambiguity costs a week.** A customer admin can self-serve a SOAP key in
  about a minute, and will cheerfully send it to you. It will not work against REST. Ask for the **REST OAuth Client
  ID, Client Secret, API Username** by those exact names, separately.
- **There is no client-credentials or service-account grant.** Bullhorn's documented OAuth is authorization-code only.
  A human user's login is required at least once per credential, even for an unattended integration — Bullhorn's own
  guidance for "OAuth 2 clients without user agents" is to perform the authorization step **outside** normal
  operation and then live on refresh tokens.
- **The credential is bound to a Bullhorn *user*, and therefore to that user's permissions.** There is no app
  identity with its own permission set. See §7.
- **Sandbox and production are different clusters with different hosts and different keys.** Bullhorn's knowledge
  base is explicit for the one documented request workflow: "Each environment requires its own Client ID and Client
  Secret. Submit a separate support ticket for each environment you plan to deploy to."

> **Product fact — dated.** As of **2026-09-20**, this platform's Bullhorn connector uses **OAuth 2.0 only**, and
> ships **no shared credentials**: every workspace must supply its own, which in practice means its own relationship
> with Bullhorn. It asks each workspace for **four** values, in this order — **OAuth2 Client ID, OAuth2 Client
> Secret, API Username, API Password** — which matches exactly the four values Bullhorn's knowledge base says Support
> issues. Its on-file default hosts are `https://auth.bullhornstaffing.com/oauth/authorize`,
> `https://auth.bullhornstaffing.com/oauth/token` and `https://rest.bullhornstaffing.com`, with a long list of
> per-data-centre alternatives recorded but **disabled**, because the hosts are resolved at runtime instead (below).
> **Data-centre discovery happens at authorize time:** before building the consent URL it takes the **API Username**
> the workspace supplied, calls `https://rest.bullhornstaffing.com/rest-services/loginInfo?username=<that username>`,
> and rewrites the authorize URL, the token URL and the API base from the `oauthUrl` and `restUrl` the response
> returns, persisting them for that connection. On the authorize request it sends `client_id`, `state`,
> `response_type` and the literal `action=login`; it sends **no `scope`**, **no PKCE challenge**, and — deliberately —
> **no `redirect_uri`**, relying on the callback registered on the key. At the token endpoint it POSTs `grant_type`,
> `code`, `client_id` and `client_secret` as **parameters**, not as an HTTP Basic header; refreshes send `client_id`,
> `client_secret`, `refresh_token` and `grant_type`. **Immediately after a successful token exchange, and again after
> every refresh, it performs the second step**: a call to `<discovered REST base>/rest-services/login` with the OAuth
> access token and `version=*`, takes the `BhRestToken` and the `restUrl` from the response, **replaces the stored
> access token with the `BhRestToken`**, and normalizes the REST base to a trailing slash. REST calls then carry the
> session key in a **`BHRestToken` header**, not an `Authorization: Bearer` header. Its recorded guidance to
> customers is the Bullhorn support-ticket route plus "Make sure that the redirect URL is …/oauth/code", and its
> recorded rate-limit handling is Bullhorn's own advice: wait one second and retry until successful. **Confirm all of
> this with the connector's owner before acting on it** — connector configuration changes independently of this
> skill.

## 2. Reuse the existing credentials, or request new ones

New credentials mean a **new client ID, and every existing customer authorization is bound to the old one** — every
customer re-authorizes, and on Bullhorn that means every customer files a ticket or clicks through consent again.
Reuse what exists for: adding or changing a redirect URI, enabling refresh tokens on a key that lacks them, rotating a
compromised secret, or diagnosing a failure. All four are handled on the existing key, by support ticket (§4).

Request a **new** key only when the user explicitly wants one: a separate product, a replacement for a compromised
client, or a new environment — which *is* a new key by Bullhorn's design, not a setting.

One Bullhorn-specific wrinkle that pushes the other way: because rate limits are **per client ID and shared across
every tenant using it** (§8), a platform at scale may legitimately want more than one client ID purely for capacity.
That is a conversation with Bullhorn Alliances, not something to engineer around quietly.

## 3. The contract gate: the API Access Program

This section applies when the **platform** is getting credentials, rather than each customer.

Entry point: `partners@bullhorn.com`, or the form on the Bullhorn Marketplace / "Integrate with Bullhorn" page.
Bullhorn's published onboarding sequence:

1. **Bullhorn Alliances sends the contract for signature.** The FAQ states plainly that redlines are not accepted:
   "we do not require that same level of access to the integrator solution … The terms set out in the contract, in
   particular in the Limitations of Liability and Indemnification sections, are meant to mitigate our risk and may not
   be made mutual."
2. **Finance issues an invoice for the annual platform fee**, payable in full at the start of each annual term, with
   no discounts and no monthly or quarterly plan.
3. **A Security Assessment** must be completed and passed by Bullhorn's compliance team **before** sandbox access.
4. **Sandbox access** arrives once the assessment passes and the invoice clears.
5. **Validation** — needed before you may promote or list the integration — is a *separate* paid engagement with
   Bullhorn's Technical Services team.

What the fee buys, per Bullhorn: a sandbox, technical resources, and **200,000 API calls per month**, with overage
charges beyond that and the option to pre-commit to more volume at a higher annual cost.

Things this section deliberately does not tell you, because Bullhorn does not publish them: the fee amount, the
turnaround time for each step, the contents of the security assessment, and whether a unified-API platform is
eligible at all given the middleware prohibition in §1. **All four are questions for Bullhorn, and all four are
business decisions.** Do not fill in security, compliance or commercial claims on the user's behalf — collect the
questions and hand them back.

**If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give
the user an exact, ordered path with the literal values to paste — the callbacks from §4 — or a ready-to-send message
to `partners@bullhorn.com`, then continue once they report back.

## 4. Requesting the credential from Bullhorn Support

This is the per-customer path, and it is also the mechanics of how any Bullhorn REST key gets made. Bullhorn's
knowledge base documents the request workflow step by step for one specific developer scenario; the shape generalizes,
but **note the scoping honestly** when you quote it — Bullhorn has not published an equivalent article for the
general partner case.

**Who files it:** the customer's authorized support contact, through the Bullhorn Resource Center. A third party may
be named: Bullhorn's FAQ on that article says "Can a third-party developer or partner request credentials on behalf
of a client? Yes. Include your developer contact information in the ticket. The credentials will be sent to the
contact listed as the Owner's Email on the key request."

**What the ticket must contain** — put all of it in the first message, because each round trip is business days:

- That you are requesting **REST API OAuth credentials** (say REST explicitly; see the SOAP trap in §1).
- The **developer contact name and email** the credentials should be sent to (the Owner's Email).
- The **purpose** of the credentials and the name of the integration.
- The **target environment** (sandbox/staging or production) — one ticket per environment.
- The **redirect URI(s)**, exactly, character for character (§5). Bullhorn's FAQ calls the redirect URI "a required
  part of the OAuth key configuration."
- **That the key must be configured to issue refresh tokens.** Bullhorn's OAuth overview says "An OAuth API key *can
  be configured* to return long-lived refresh tokens in addition to access tokens," and the Getting Started page
  hedges the same way: "*If* Bullhorn has provided you with the ability to generate refresh tokens…". A key without
  them forces a full re-authorization every ten minutes. **Ask for them by name.**

**What comes back, and what does not.** Bullhorn Support delivers the **Client ID, Client Secret and API Username**
through the support case. The **API user's password is not issued** — per Bullhorn, "Before passing the credentials
to your developer, you will need to generate a password for the API user." Somebody on the customer side must set it
in the Bullhorn front end. Allow "a few business days" for processing, and expect an internal configuration step on
Bullhorn's side that can silently be incomplete — Bullhorn's own troubleshooting advice for a key that will not work
is to "follow up on your original support ticket to confirm that all processing steps have been completed on your
key."

**There is no console to re-read the secret in.** Treat it as shown once and store it immediately. Rotation is
another ticket: "Contact Bullhorn Support to request a new Client Secret."

**The flow itself**, once you hold credentials. Hosts are per customer — `{lane}` comes from §5, and there is no
correct value to hardcode:

- Authorize: `GET https://auth-{lane}.bullhornstaffing.com/oauth/authorize` with `client_id`, `response_type=code`,
  `action=Login`, `state` (recommended by Bullhorn, and your CSRF defence) and an optional `redirect_uri`. Bullhorn
  documents an optional `username`/`password` pair on this URL that skips the login page — **do not use it for a
  multi-tenant flow**; let the customer type their own credentials on Bullhorn's page. Without them the user gets a
  Bullhorn login page, then a Terms of Service page which, per Bullhorn, "is only displayed the first time a user's
  credentials are provided for the specified clientId."
- Exchange: `POST https://auth-{lane}.bullhornstaffing.com/oauth/token` with `grant_type=authorization_code`, `code`,
  `client_id`, `client_secret` and — only if you sent one on authorize — the **same** `redirect_uri`.
- Then, and this is the part people miss, **log in** (§6). The access token is not an API credential.

## 5. Redirect URIs, and the swimlane that is not yours to choose

### The callbacks

Register **every** callback host in the same ticket. Bullhorn's OAuth overview refers to "the redirect URI(s) that you
provide to Bullhorn for your OAuth API key," so more than one is possible, but there is no self-service editor: each
change is another ticket and another wait. For this platform these are one per data centre; confirm the current list
with the platform owner rather than assuming:

```
https://api.unified.to/oauth/code          # us (default)
https://api-eu.unified.to/oauth/code       # eu
https://api-au.unified.to/oauth/code       # au
https://api-dev.unified.to/oauth/code      # dev
```

**A trap specific to this connector.** It deliberately does **not** send `redirect_uri` on the authorize request,
which is legal — Bullhorn documents the parameter as optional and falls back to what is registered on the key. But
Bullhorn does **not** document how it chooses when a key carries *several* registered callbacks and the request names
none. Do not guess. Either register **one callback per client ID** — a separate Bullhorn key per data centre, which
is the safe reading — or get Bullhorn to state the selection rule in writing and test it. A key registered with all
four callbacks and a flow that names none is the kind of thing that works in the US and silently misroutes the EU.

Note also that Bullhorn's `redirect_uri` rule is symmetric: if you send one on authorize you **must** send the
identical value on the token exchange. Sending on one and not the other is a classic `invalid_grant`.

### The swimlane

**This is the fact that sinks most Bullhorn integrations, and it has no analogue at most ATS vendors.** Every Bullhorn
customer lives on a numbered cluster, and **the authorization host, the token host and the REST host are all
different per cluster**. `auth.bullhornstaffing.com` is not a universal front door you can point every tenant at.

Bullhorn's instruction is to discover it, per user, at runtime:

> "Run the following GET rest-services/loginInfo request with your API_Username to return the list of correct URLs
> for that user: `https://rest.bullhornstaffing.com/rest-services/loginInfo?username={API_Username}`. **If you do not
> use the correct URLs for a user, you will receive a 307 redirect to the correct data center. Your code must be
> written to handle that 307 redirect.**"

A live response (US West, abridged) looks like this — the two fields that matter are `oauthUrl` and `restUrl`:

```json
{
  "oauthUrl": "https://auth-west.bullhornstaffing.com/oauth",
  "restUrl":  "https://rest-west.bullhornstaffing.com/rest-services",
  "atsUrl":   "https://cls31.bullhornstaffing.com",
  "dataCenterId": 3,
  "superClusterId": 31
}
```

Five things follow, and each of them is a bug somebody has shipped:

1. **Discovery must happen before the authorize redirect, not after the callback.** You need `oauthUrl` to build the
   consent URL at all. That in turn means you need the customer's **API username before** they click Connect — which
   is why it is an input on the connect form rather than something you learn from the token.
2. **`loginInfo` fails open.** An unknown username returned HTTP 200 and a full US-West payload on 2026-09-20 — not a
   404. A typo does not fail; it silently authorizes against the wrong lane, and the failure surfaces much later as
   307s or an unexplained 401. **Validate the username with the customer, and treat a lane that does not match their
   browser URL as a red flag.**
3. **Never hardcode the lane list.** Bullhorn publishes one, bylined 2018, which today omits at least the Australian
   lane (`auth-aus` / `rest-aus`, `superClusterId: 66`) while listing APAC only as `auth-syd`. Both `auth-aus` and
   `rest-aus` resolve in DNS. The published page is a useful sanity check and a terrible source of truth.
4. **Handle the 307.** Bullhorn says your code must. Many HTTP clients do not follow 307 for POST bodies by default,
   and a silently-dropped POST body is a maddening failure mode.
5. **The cluster is stable per customer.** Bullhorn: "cluster IDs do not change for a customer, so identifying the
   correct cluster ID for all users for a given customer is a one-time exercise." Cache the discovered hosts per
   connection — but re-discover on a 307 rather than trusting the cache forever.

If a customer is unsure of their cluster, it is in their own browser URL once logged in (`cls21.bullhornstaffing.com`
→ CLS21 → the UK data centre). Sandbox clusters are their own lanes again (`…-west9`, `…-east9`, `…-emea9`).

## 6. The two-step token dance, and the refresh token that is not what you think

**A Bullhorn access token is not an API credential.** It is a ten-minute ticket whose only purpose is to be traded
for a REST session. Getting this wrong produces the most confusing Bullhorn symptom there is: a textbook-clean OAuth
exchange followed by a 401 on every single API call.

The full sequence, per Bullhorn:

| Step | Call | You get | Lifetime |
| --- | --- | --- | --- |
| 1 | `GET .../oauth/authorize` | authorization code | seconds |
| 2 | `POST .../oauth/token` | `access_token`, and `refresh_token` **if the key is configured for it** | access token **10 minutes** |
| 3 | `POST https://rest-{lane}.bullhornstaffing.com/rest-services/login?version=*&access_token=…` | **`BhRestToken`** (the session key) **and a `restUrl`** | until the session expires |
| 4 | every API call | data | — |
| 5 | on `401` | back to step 2 via refresh, then step 3 again | — |

Details that matter:

- **Use the `restUrl` the login response gives you.** It is not the same string as the `restUrl` from `loginInfo` — it
  carries the customer's `corpToken`: `https://rest{swimlane#}.bullhornstaffing.com/rest-services/{corpToken}/`.
  Every subsequent entity call hangs off it. Building paths from the discovery URL instead of the login URL produces
  404s that look like missing entities.
- **The session key travels in `BhRestToken`.** Bullhorn documents three carriers — "The session key can be provided
  in the BhRestToken query string, a cookie, or an HTTP header." A header is the sane choice; a query string puts a
  live session key into every access log you and Bullhorn keep.
- **Do not log in per request.** Bullhorn is unusually emphatic: "For performance and load reasons you must **NOT**
  perform a fresh REST API login before every API request… Note that Bullhorn will apply strict limits to login
  rates, and may block login requests that occur too frequently." A platform that re-logs-in on every call will be
  throttled, and the failure will look like an auth bug.
- **The REST session's lifetime is not a published number.** Bullhorn says only "until the session expires" and gives
  you `GET /ping`, which returns `{"sessionExpires": <epoch ms>}` and is the documented way to test whether a session
  is still valid. **Do not hardcode a session TTL from a forum post.** Drive re-authentication off the 401, and use
  `/ping` when you need to check without spending a real call.
- **Refresh tokens are single-use and rotate.** Bullhorn's OAuth overview: "A refresh token expires after it is used
  once to successfully obtain a new access token and refresh token." The Getting Started page says the same from the
  other side: "A new refresh token is returned with every new access token. The refresh token has no expiration
  date/time, but it does expire when a new access token and refresh token are generated." **Persist the new refresh
  token from every refresh response, transactionally.** A client that replays the original works exactly once. A
  crash between "refresh succeeded" and "new token saved" permanently bricks that connection.
- **Refresh tokens are an option on the key, not a guarantee.** If a key was issued without them, the connection
  cannot survive ten minutes unattended and no amount of client code will fix it. That is a support ticket (§4).
- **Plan for unrecoverable refresh failures.** Bullhorn explicitly puts this on the integrator: "the partner must
  provide a means to get a new refresh token to account for situations where a refresh token encounters an
  unrecoverable error. The resource owner may be notified of the failure by email, sms, or another means to inform
  them go through the full authorization process." A re-consent path is a product requirement here, not a nicety.
- **Client credentials go in the request, not an `Authorization` header.** Bullhorn's documented token and refresh
  calls carry `client_id` and `client_secret` as parameters. HTTP Basic is not documented.
- **PKCE and `scope` do not appear.** Do not send them on the strength of habit (§7, Platform state).

## 7. There are no scopes — entitlements are the access control

Bullhorn's OAuth has no `scope` parameter, no consent checklist, and no per-app permission model. **What your
integration can do is exactly what the authorizing Bullhorn user can do**, decided inside the customer's Bullhorn
configuration, invisible to you at authorize time, and unreportable in the token response. This is the second-biggest
source of "it works for us and 403s for them."

The model, as Bullhorn documents it:

- `GET /entitlements/{entityType}` returns the current user's entitlements for an entity, e.g.
  `["CREATE","READ","READ_DEPARTMENT","UPDATE","DELETE"]`. **This is your pre-flight check** — call it at connection
  time and tell the customer what is missing, rather than discovering it as a 403 during a sync.
- Read, update and delete each come in **owned / department / corporate** flavours: `READ` (own records only),
  `READ_DEPARTMENT`, `READ_CORPORATE`, and the matching `UPDATE*` / `DELETE*`. `CREATE` is flat. A user with only
  `READ` sees their own records and an *empty list* for everyone else's — **not an error**. An integration that
  reports "this customer has 4 candidates" when they have 40,000 is almost always looking at an owned-only
  entitlement.
- `UPDATE_OWNER` is separate, and applies to Candidate, ClientContact, JobOrder, JobSubmission, Lead and Opportunity.
  Update rights do not imply the right to change an owner.
- **Private records need `READ_PRIVATE`.** Without it a user "cannot read private entities owned by other users."
- **Private file attachments** need the "View All Private Attachments" entitlement, or ownership, or an explicit
  share, or department membership. Attachment rights follow the entitlements of the **parent entity**.
- **Confidential fields have their own gates**: editing them "as part of a POST /entity call" requires the "Edit
  Confidential Data" user action entitlement, and the set of confidential fields is a per-customer private-label
  attribute.
- **The silent one.** Bullhorn: "For nested to-many associations for which the user does not have read entitlements,
  only data for predefined fields is returned. The other fields are returned with a value of 'null'." Missing
  permissions can arrive as `null`s in a `200`, with no error anywhere. Any mapping that treats `null` as "the
  customer has no data" will quietly lie.

Practical consequences:

- **Ask the customer which Bullhorn user will authorize, and get corporate-level entitlements on the entities you
  need.** The right answer is usually a dedicated API user, which is why Bullhorn Support issues an API Username
  alongside the key.
- **User types are not readable over REST.** If you need to know which user type a mutual customer's API user sits in,
  the customer has to ask Bullhorn. Do not build a feature that depends on reading it.
- Because there are no scopes, **adding a feature never requires re-consent** — it requires the customer's admin to
  widen a user's entitlements. That is a support conversation you own, and it should be in your setup docs.

## 8. Limits, editions, and what does not count

From Bullhorn's knowledge base, for all ATS editions that have API access:

| Limit | Value |
| --- | --- |
| Calls per minute | **1,500**, scoped to the **OAuth Client ID** — shared by every tenant on that client ID |
| Total calls per month | **100,000** for a customer key, "unless otherwise agreed upon with Bullhorn" |
| Concurrent API sessions | **50** active |
| API subscriptions (event subscriptions) | **50** active; unused subscriptions and un-retrieved events have a **30-day TTL** |
| Contracted integrator allowance | **200,000 calls/month** included in the API Access Program, overage charged |

And the parts that change the arithmetic:

- **"API usage associated with validated Bullhorn partners does not count toward your API call limits. API users
  created by Bullhorn Support for partner integrations also do not count against your API user limit."** Validation
  is therefore not only a marketing badge — it materially changes whether your traffic eats the customer's budget.
- **The per-client-ID rate limit is the multi-tenant killer.** One shared client ID means one shared 1,500/min across
  every customer you connect. Model this before promising throughput, and raise it with Alliances early.
- **Two different 429s.** "Too Many Requests, API rate limit exceeded" means you exceeded 1,500/min for your client
  ID; "Server API Capacity Exceeded" means the server is loaded and is "unrelated to your call volume." Both are
  handled the same way: wait one second, retry, repeat — Bullhorn says most clear within ten retries, and warns
  "Ensure that there are no retry limits for 429 responses in your integration, or your application may not recover
  correctly." Usefully, **calls that return 429 do not count against your usage limits**.
- **Some editions have no API at all.** Bullhorn's limits page: the limits apply to all ATS editions "except ATS
  Growth (formerly Team Edition). ATS Growth edition does not include access to the Bullhorn API for custom
  integrations." A separate knowledge-base page lists the requirement as "Front Office Growth or Enterprise edition
  of Bullhorn." **These two pages use different product names and do not obviously agree** — if edition eligibility
  is load-bearing for a deal, confirm it with Bullhorn rather than quoting either page.
- **Versioning:** older API versions are supported for at least three years after a successor ships, with at least
  six months' end-of-life notice on the Bullhorn Resource Center. Build against the latest version.
- **Listing** happens on the Bullhorn Marketplace, after the paid Technical Services validation engagement (§3).
- **Branding:** the Fair Use Policy forbids using Bullhorn's name, trademarks or logos without a written agreement —
  which includes the connect button and the marketing page, not just the logo file.

## 9. Verify end-to-end

Do not stop at "the token came back." On Bullhorn, the token coming back proves almost nothing.

1. Run `loginInfo` for the customer's API username and **read the result out loud to the customer**: does the
   `atsUrl` cluster match the `cls##` in their own browser URL? If not, the username is wrong (the endpoint will not
   tell you).
2. Authorize through the platform's real connect flow against a **second** Bullhorn tenant — ideally on a **different
   swimlane** from the first. A single-lane test proves nothing about discovery, which is the thing most likely to be
   broken.
3. Confirm the token response actually contains a **`refresh_token`**. If it does not, the key was issued without
   them (§4) and everything after this is wasted effort.
4. Perform the **login step** and confirm you get both a `BhRestToken` and a `restUrl`, and that your first entity
   call is built from **that** `restUrl` (with its `corpToken`), not from the discovery URL.
5. **Refresh, then refresh again using the token returned by the first refresh.** Two minutes of work for a failure
   that otherwise appears ten minutes after the customer walks away.
6. Kill the session deliberately (or wait one out), confirm you get a `401`, and confirm the client recovers by
   refreshing and re-logging-in rather than by hammering `/login`.
7. Call `GET /entitlements/{entityType}` for every entity the integration touches, as the user who will actually
   authorize, and compare it against what you need. Then repeat step 4 as a **deliberately under-privileged** user
   and confirm your code reports a permission problem instead of reporting "no data."
8. Point the client at a deliberately wrong lane and confirm it survives the **307** with its POST body intact.
9. Exercise pagination past the first page, and confirm your 429 handling has **no retry cap**.

| Symptom | Cause |
| --- | --- |
| OAuth exchange succeeds, every API call 401s | The access token was used as an API credential; the `/rest-services/login` step was skipped (§6) |
| 307 redirects, or POST bodies vanishing | Wrong data-centre host; client not handling 307 on POST (§5) |
| Works for US customers, fails for EU/AU | Hardcoded auth/REST hosts instead of per-user `loginInfo` discovery (§5) |
| `loginInfo` returns a plausible US-West lane for a customer who is plainly in the UK | The API username is wrong — the endpoint fails open with a default (§5) |
| Entity calls 404 on paths that should exist | REST base built from the discovery `restUrl` rather than the login `restUrl` with its `corpToken` (§6) |
| Second refresh fails, first succeeded (`invalid_grant`) | Refresh tokens are single-use; the rotated token was not persisted (§6) |
| Connection dies exactly ten minutes after connecting | Key issued without refresh-token capability (§4, §6) |
| Connection bricked after a crash mid-refresh | Rotated refresh token lost between "issued" and "saved" — needs a re-consent path (§6) |
| `invalid_grant` on the initial exchange | `redirect_uri` sent on authorize but not on token, or vice versa, or not matching the registered value (§4, §5) |
| Callback lands on the wrong data centre's host | Several callbacks registered on one key while the flow sends no `redirect_uri` (§5) |
| Throttled on `/login` specifically | Logging in before every request; Bullhorn applies strict login-rate limits (§6) |
| 403 on one customer only | Authorizing user's entitlements, not a scope or a key problem (§7) |
| List returns far fewer records than the customer expects, no error | Owned-only entitlement (`READ` without `READ_DEPARTMENT`/`READ_CORPORATE`) (§7) |
| Nested association fields arrive as `null` in a `200` | No read entitlement on the association — Bullhorn returns nulls, not errors (§7) |
| Private notes or attachments missing with no error | `READ_PRIVATE` / "View All Private Attachments" not granted (§7) |
| `429 Too Many Requests, API rate limit exceeded` under modest per-customer load | 1,500/min is shared across every tenant on that client ID (§8) |
| `429 Server API Capacity Exceeded` | Bullhorn-side load, unrelated to your volume; same retry (§8) |
| Integration stops recovering from 429s | A retry cap in the client — Bullhorn says there must not be one (§8) |
| Customer "already generated an API key" and it does not work | They generated a SOAP/web-services key, not a REST OAuth key (§1) |
| Event subscriptions silently stop delivering | 50-subscription cap, or the 30-day TTL on unused subscriptions/un-retrieved events (§8) |
| Customer cannot get a key at all | Their edition may not include API access, or Bullhorn declined because an uncontracted third party was named (§1, §8) |

## 10. Hand off — never commit the secret

- **Do not** write a client secret, API-user password or session key into source control, a test, a fixture, a
  committed `.env`, a ticket, a PR body or a chat channel. Bullhorn says so itself on its own credential-request
  page: "Do not commit credentials to source control or share them outside your team." The Fair Use Policy goes
  further and makes API keys **contractually confidential**, and forbids resharing or transferring them.
- A `BhRestToken` is a **live session key**, not a derived value. Treat it as a secret with the same care as the
  client secret, keep it out of URLs and logs, and do not persist it anywhere a support transcript could reach.
- If a code change is needed (a callback host, the discovery step, retry behaviour), keep it secret-free and say
  plainly what the human must set out of band.
- Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and
  can be rotated by ticket, then move on.
- Close with: which credential model you ended on and whether Bullhorn has agreed to it in writing; the client ID (or
  which customer holds which key); where the secret was delivered; the environment and its swimlane; how the hosts
  are discovered at runtime; whether the key was confirmed to issue refresh tokens; the entitlements the authorizing
  user needs, in words the customer's Bullhorn admin can act on; the shared per-client-ID rate-limit budget; and
  whatever is still waiting on Bullhorn.

## Stop and ask

Hand back to a human rather than guessing when: **the platform is a connector, middleware or unified-API layer and
has no signed Bullhorn agreement** — say so in the first reply, quote the FAQ, and do not start a request that
Bullhorn's published policy says will be refused; the API Access Agreement, security assessment or validation
engagement needs business, security or compliance claims; someone proposes re-issuing credentials on a live
integration (that re-authorizes every customer); a customer's support ticket comes back refused or asks who the third
party is; the **data centre cannot be determined** for a customer, or `loginInfo` disagrees with the cluster in their
browser URL; a key turns out not to issue refresh tokens and the customer will not re-ticket; the integration will
expose Bullhorn data to an AI or LLM feature, or to an MCP server (the Fair Use Policy requires explicit written
permission); expected volume approaches the per-client-ID rate limit or the monthly call cap; or the developer pages
and knowledge base no longer match the Platform state section above.

Note also that this skill was written without browser automation: every fact here came from fetching Bullhorn's
public documentation and probing its hosts and its `loginInfo` endpoint directly. Nothing here was read from a
logged-in portal, and Bullhorn's documentation is thinner and older than most vendors' — where a fact could not be
found on a Bullhorn-owned page, this skill says so rather than filling the gap. A run that also has no browser should
hand click paths and ticket text to the user rather than simulating a session.

## References

Verified to return HTTP 200 on 2026-09-20.

- Get Started with the Bullhorn REST API (data-centre discovery, authorize/token/login calls, 10-minute access token, refresh rotation, login-rate warning) — https://bullhorn.github.io/Getting-Started-with-REST/
- Bullhorn OAuth Authorization (client registration, refresh-token configuration, single-use refresh, terms-of-service screen, clients with and without user agents) — https://bullhorn.github.io/docs/oauth/
- Data-center-specific API URLs (the published lane list — bylined 2018; see Platform state) — https://bullhorn.github.io/Data-Center-URLs/
- API Fair Use Policy (registration requirement, third-party access, credential sharing, AI/LLM and MCP clauses, branding) — https://bullhorn.github.io/api-fair-use-policy/
- Developer FAQ (customer keys by support ticket; partner keys only from Bullhorn; `partners@bullhorn.com`; SOAP key self-service) — https://bullhorn.github.io/FAQ/
- REST API Reference (error codes, `/entitlements`, `/ping` and `sessionExpires`, `BhRestToken` carriers, null-on-no-entitlement behaviour) — https://bullhorn.github.io/rest-api-docs/index.html
- REST API Reference (index) — https://bullhorn.github.io/rest-api-docs/
- Entity Reference — https://bullhorn.github.io/rest-api-docs/entityref.html
- Bullhorn developer documentation home — https://bullhorn.github.io/
- Bullhorn developer docs index — https://bullhorn.github.io/docs/
- Bullhorn developer guides — https://bullhorn.github.io/guides/
- API Usage Limits, Versioning, and Backward Compatibility (1,500/min per client ID, 100k/month, 50 sessions, 50 subscriptions, the two 429s, edition exclusion, validated-partner exemption) — https://kb.bullhorn.com/ats/Content/BHATS/Topics/understandingBHAPIUsageLimitsVersioningBackwardCompatibility.htm
- Request REST API Credentials (the four credential values, "not self-service", what the ticket must contain, API-user password, per-environment keys, secret rotation) — https://kb.bullhorn.com/ats/Content/BHATS/Topics/BHExtensionConfigStarter.htm
- Customizing Bullhorn with Third Party Solutions (Marketplace vs custom API build, edition requirement) — https://kb.bullhorn.com/ats/Content/BHATS/Topics/customizingBHWithThirdPartySolutions.htm
- Bullhorn knowledge base home — https://kb.bullhorn.com/
- Bullhorn API Access FAQs (the middleware prohibition, annual platform fee, 200k calls, no redlines, onboarding order, validation as a paid engagement) — https://www.bullhorn.com/bullhorn-partner-program-faqs/
- Integrate with Bullhorn (the API Access application form) — https://www.bullhorn.com/become-a-partner/
- Bullhorn Partner Program Guide — https://www.bullhorn.com/partner-program-guide/
- Bullhorn Platform Partner Program (Technical Services-assisted validation engagement; `partners@bullhorn.com`) — https://www.bullhorn.com/partner-program-guide/bullhorn-platform-partner-program/
- Bullhorn Marketplace Partner Engagement Hub — https://www.bullhorn.com/partner-engagement-hub/
- Bullhorn Marketplace (marketing) — https://www.bullhorn.com/marketplace/
- Bullhorn Marketplace: Understanding the Ecosystem — https://www.bullhorn.com/marketplace/understanding-the-partner-ecosystem/
- Bullhorn Marketplace listings — https://marketplace.bullhorn.com/
- Bullhorn support (route to the Resource Center for credential tickets) — https://www.bullhorn.com/support/
- Bullhorn developer articles — https://developer.bullhorn.com/articles
- Bullhorn developer documentation — https://developer.bullhorn.com/documentation
