Two one-page references for the Bullhorn and JobAdder REST calls an AI recruiting agent actually leans on: get logged in, read a job and a candidate, pull a CV, search, write a decision back. Everything below is condensed from verified research, the same groundwork that sits behind connecting an agent to your ATS and the use-case builds.
A standing health warning, because it's the whole thesis in miniature: these are correct as of writing, and APIs drift. Both the Bullhorn and JobAdder rows are from fully public official docs and are high-confidence — Bullhorn's at bullhorn.github.io, JobAdder's from its official OpenAPI v2 spec. That's the floor, not the ceiling: tokens expire, hosts move, and "stable" is a verb.
A cheat-sheet has a shelf life. That's not a flaw in the cheat-sheet — it's the reason someone has to own it.
Verified against the official docs at bullhorn.github.io.
Every host is swimlane-specific (data centre: west, east, emea, …). Resolve the swimlane first; never hardcode hosts.
| Step | Call | Returns / note |
|---|---|---|
| 0. Data centre (no auth) | GET https://rest.bullhornstaffing.com/rest-services/loginInfo?username={apiUsername} | oauthUrl + restUrl bases for the user's swimlane. Do this first, always. |
| 1. Authorize | GET https://auth-{dc}.bullhornstaffing.com/oauth/authorize?client_id={id}&response_type=code&action=Login&username={u}&password={p}&redirect_uri={uri}&state={csrf} | Redirects to redirect_uri?code={authCode}. action=Login&username&password is the documented headless variant; the interactive flow omits credentials. |
| 2. Token | POST https://auth-{dc}.bullhornstaffing.com/oauth/token?grant_type=authorization_code&code={authCode}&client_id={id}&client_secret={secret}&redirect_uri={uri} | { access_token, refresh_token, expires_in }. Refresh token issued only if Bullhorn enabled it for the client. |
| 3. REST login | POST https://rest-{dc}.bullhornstaffing.com/rest-services/login?version=2.0&access_token={access_token} | { BhRestToken, restUrl }. restUrl is the base for all entity calls and embeds corpToken — read it, don't hardcode it. |
| Refresh | POST .../oauth/token?grant_type=refresh_token&refresh_token={rt}&client_id={id}&client_secret={secret} | New access_token and new refresh_token (rotates — persist the newest). Then redo step 3 for a fresh BhRestToken. |
| Thing | Lifetime / behaviour |
|---|---|
access_token | 10 minutes. |
refresh_token | No time expiry, but single-use — rotates on every refresh. Persist the newest or you lock yourself out. |
BhRestToken (session) | Reuse it — docs explicitly warn against logging in fresh per request (load). Pass as ?BhRestToken={token} query param, BhRestToken header, or cookie. Treat HTTP 401 as "expired", then refresh → re-login. |
All relative to restUrl; append &BhRestToken={token} to every call.
| Operation | Call | Note | |||
|---|---|---|---|---|---|
| Get job | GET {restUrl}entity/JobOrder/{id}?fields=id,title,status,clientCorporation,clientContact,employmentType | fields= (or layout=) is required — 404 without it. | |||
| Get candidate | GET {restUrl}entity/Candidate/{id}?fields=id,firstName,lastName,email,status,occupation | Same fields= rule. | |||
| List CV / files | GET {restUrl}entity/Candidate/{id}/fileAttachments?fields=id,name,contentType,fileSize,type,dateAdded,isResume | fileAttachments replaces the deprecated /entityFiles. | |||
| Download a file | GET {restUrl}file/Candidate/{id}/{fileId} | Returns { File: { name, contentType, fileContent (base64) } }. Append /raw for raw bytes. | |||
| Upload a file | PUT {restUrl}file/Candidate/{id} | Base64 JSON or multipart body (externalID, fileContent/file, fileType, name). | |||
| Parse CV → text | `POST {restUrl}resume/parseToCandidate?format={DOC\ | HTML\ | PDF\ | DOCX}` | Multipart resume body. Add &populateDescription=text (or html) to get the body as plain text. No separate convert-to-text endpoint — text comes via populateDescription. |
| Search (full-text, eventually-consistent) | GET {restUrl}search/{Entity}?query={lucene}&fields=...&start=0&count=20&sort=... | Lucene index. e.g. search/Candidate?query=isDeleted:0 AND occupation:"Engineer". Can search nested file data, e.g. fileAttachments.description:(+Manager +IT). | |||
| Query (DB, strongly consistent) | GET {restUrl}query/{Entity}?where={jpql}&fields=...&start=0&count=... | JPQL. e.g. where=lastName='Smith' AND status='Active'. Use POST if where > ~7,500 chars. | |||
| Write a note | PUT {restUrl}entity/Note | Body e.g. { "action":"Outbound Call", "comments":"...", "personReference":{"id":{candidateId}}, "jobOrder":{"id":{jobId}} }. Returns { changedEntityId, changeType:"INSERT" }. | |||
| Update a field | POST {restUrl}entity/Candidate/{id} | Body e.g. { "status":"Placed" } → changeType:"UPDATE". Read-only fields rejected. (Create a non-note entity = PUT entity/{Entity}.) |
| Concern | Behaviour |
|---|---|
| Rate limit | On HTTP 429, wait 1s and retry until success — implement exponential backoff. No published per-second quota. [VERIFY] numeric limits with Bullhorn support. |
| Pagination | start (offset, default 0) + count (page size, default 20; max varies, commonly up to 500). Response includes total. Loop start += count. |
| Consistency | search is index-backed (lag — new records may not appear immediately). Use query when you need read-after-write consistency. |
| Sandbox | No single public sandbox — request a test corp from Bullhorn. Always resolve the swimlane via loginInfo. |
Docs: bullhorn.github.io/Getting-Started-with-REST/ · bullhorn.github.io/docs/oauth/ · bullhorn.github.io/rest-api-docs/ · bullhorn.github.io/Resume-Parsing/
Verified against JobAdder's official OpenAPI v2 spec. It's public and high-confidence — no [VERIFY] rows here.
| Thing | Value |
|---|---|
| Identity (auth) host | https://id.jobadder.com (authorize + token) — same host for prod and test |
| API host | https://api.jobadder.com/v2 — but don't hardcode it. The token response returns a per-account api base URL; store it with the tokens and prefix every call with that. |
| Prerequisite | Register an application at developers.jobadder.com/register to get a client_id and client_secret. No partner agreement needed. For a sandbox, register a separate Test application (its own client id/secret) and connect a test account — there is no separate sandbox host. |
| Step | Call | Returns / note |
|---|---|---|
| 1. Authorize | GET https://id.jobadder.com/connect/authorize?response_type=code&client_id={id}&scope={scopes}&redirect_uri={uri}&state={csrf} | Redirects to redirect_uri?code={authCode}&state=.... Auth code valid 5 minutes. |
| 2. Token (first exchange) | POST https://id.jobadder.com/connect/token (form-encoded) body grant_type=authorization_code&client_id={id}&client_secret={secret}&code={authCode}&redirect_uri={uri} | { access_token, expires_in:3600, token_type:"Bearer", refresh_token, api:"https://api.jobadder.com/v2" }. Store the api base URL with the tokens — it prefixes every call. |
| 3. Refresh (rotation) | POST https://id.jobadder.com/connect/token body grant_type=refresh_token&client_id={id}&client_secret={secret}&refresh_token={rt} | New access_token and a new refresh_token (rotating) + a fresh api URL. Persist the new refresh token every time or you lock yourself out. Refresh requires the offline_access scope. |
Every v2 call authenticates with the OAuth access_token as a standard bearer token. There's no second static key.
| Thing | Value | Lifetime |
|---|---|---|
Authorization: Bearer {access_token} | The access_token from token/refresh | expires_in:3600 (~60 minutes); refresh proactively. |
refresh_token | Returned on token and on every refresh — rotates | No fixed expiry, but single-use. Persist the newest. Needs the offline_access scope. |
| Scopes | read, write, offline_access plus fine-grained (read_job, read_candidate, write_note, read_candidate_note, …) | Requested at authorize time; grant only what each agent needs. |
Base = the api URL from the token response (e.g. https://api.jobadder.com/v2). Header Authorization: Bearer {access_token} on each.
| Operation | Call | Note |
|---|---|---|
| Get candidate | GET /candidates/{candidateId} | firstName, lastName, email, skillTags[], education[], …. |
| Get job | GET /jobs/{jobId} | Skills/requirements live in skillTags.tags (JobOrderSkillTags = { matchAll, tags[] }); also category / custom fields. No /jobs/{id}/skills. |
| List CV / files | GET /candidates/{id}/attachments?type=Resume&latest=true | Each item { attachmentId, type, category, fileName, fileType }. |
| Download a file | GET /candidates/{id}/attachments/{attachmentId} | Returns the raw binary file. |
| CV → text | No parsed-resume-text endpoint. Closest is full-text search over the latest resume: GET /candidates?keywords={terms} | "Search for key words within the latest candidate resume." Extract text yourself if you need the body. |
| Search / list | GET /candidates?offset={n}&limit={n} · GET /jobs?offset={n}&limit={n} | limit max 1000; limit=0 returns only totalCount. Response envelope { items:[…], totalCount, links:{ first, prev, next, last } }. |
| Write a note / activity | POST /candidates/{candidateId}/notes (also POST /jobs/{jobId}/notes) | Body AddCandidateNoteCommand { text (REQUIRED), type?, applicationId?[], reference? } → 201 NoteModel. JobAdder has no separate "activity" resource — activities are notes. (Status moves via PUT /candidates/{id}/status.) |
| Concern | Behaviour |
|---|---|
| Token lifetime | access_token expires_in:3600 (~60 min) — refresh before then. |
| Refresh token | Rotates on every refresh (new refresh_token in the body each time) — persist the newest. Requires the offline_access scope. |
| Rate limits | JobAdder applies API throttling, but the exact numbers live behind its Zendesk help centre. Don't invent one — consult JobAdder's API Throttling guide (or api@jobadder.com) and implement HTTP 429 backoff defensively. |
| Pagination | offset + limit (max 1000) → envelope { items, totalCount, links{ first, prev, next, last } }. Prefer following links.next over computing offsets. |
| Sandbox vs prod | No separate sandbox host. Register a separate Test application at developers.jobadder.com/register (own client id/secret) and connect a test account. |
Docs: api.jobadder.com/v2/docs (interactive) · developers.jobadder.com · spec mirror github.com/vitaliymashkov/jobadder-api/blob/master/jobadder-openapi-v2.json
| Bullhorn | JobAdder | |
|---|---|---|
| Auth model | OAuth2 → REST login (3-legged + swimlane lookup) | OAuth2 authorization code |
| Per-call credential | BhRestToken (session) | Authorization: Bearer {access_token} (one header) |
| Short-token life | access_token 10 min; session reused until 401 | access_token expires_in:3600 (~60 min) |
| Refresh token | Rotates (single-use) | Rotates (new refresh token on every refresh); needs offline_access |
| Job entity | JobOrder | /jobs/{id} (skills in skillTags.tags) |
| Search vs query | search (index, lazy) and query (DB, consistent) | List with offset/limit; resume full-text via ?keywords= |
| Write-back | PUT entity/Note | POST /candidates/{id}/notes (activities = notes) |
| Sandbox | Request a test corp; resolve swimlane | Register a separate Test application |
| Doc confidence | Public official docs (high) | Public OpenAPI v2 spec (high) |
Two systems, one job: read the spec, do the work, write the decision back where a human will see it. The auth dance is just the cover charge.
For where every figure and claim traces back to, see Sources & Further Reading.
Download the full PDF for free?