Bullhorn (platform)

Bullhorn is the staffing-industry ATS and CRM: the JobOrder is the demand record, JobSubmission the pipeline and Placement the filled engagement, whether permanent or contract. The REST API is entity-oriented (one create, update, query and search surface per entity type) behind OAuth 2.0 plus a per-session BhRestToken, and change events arrive through a subscription queue the client drains rather than through webhooks. composerID creates the JobOrder for the approved decision with externalID = Intent ID, reads it back by querying that field, and follows Placement changes on the same JobOrder to close the loop.

Publish path
Direct API
composerID adapter → Bullhorn API
Events back
Push + poll
events where enabled; poll fallback
API access
Open docs
public developer portal
Contract tier
Tier A
full loop: publish + events back
composerID carrier
Stamped
job_order externalID: written into the record and read back; indicative until confirmed at onboarding
Tenant configuration (mandatory fields, approval chains, picklists) is a separate gate from vendor capability: it is discovered at preflight before first publish and may need destination admin changes. How composerID connects →
Reference
API at a glance

The public API facts composerID's adapter relies on. Tenant-specific details (custom fields, picklists, approval chains) are confirmed during connection and folded into the MappingProfile.

AuthenticationOAuth 2.0 authorization-code flow against auth.bullhornstaffing.com (client_id and client_secret are issued by Bullhorn to a tenant holding the REST API licence or to a Marketplace partner, and the grant is made as a named Bullhorn API user). The access token is short-lived (about ten minutes) with a long-lived refresh token; a call to /rest-services/login with that access token returns a BhRestToken plus the tenant's restUrl. The BhRestToken is sent on every call and lapses after inactivity: keep one session warm and re-login on 401.
API styleREST, JSON. Entity-oriented: PUT /entity/{EntityType} creates, POST /entity/{EntityType}/{id} updates, GET /entity/{EntityType}/{id}?fields= reads; /query/{EntityType} takes a where clause, /search/{EntityType} takes Lucene syntax; /meta/{EntityType} describes each tenant's fields, labels and picklists; /event/subscription is a pull-based change queue.
Base URLhttps://rest{dc}.bullhornstaffing.com/rest-services/{corpToken}/ (the data centre and corpToken are returned per tenant by /rest-services/loginInfo and the login call; auth at https://auth.bullhornstaffing.com/oauth)
ObjectsJobOrder (title, employmentType, status, numOpenings, startDate, clientCorporation, clientContact, externalID, payRate, clientBillRate, salary, customText1..40 plus customInt and customDate families), JobSubmission, Placement (candidate, jobOrder, status, dateBegin, dateEnd, payRate, clientBillRate), Candidate, ClientCorporation, ClientContact, Opportunity, Note, Task, event subscriptions
Events / webhooksNo outbound webhooks. Event subscriptions instead: PUT /event/subscription/{subscriptionId}?type=entity&names=JobOrder,Placement,JobSubmission&eventTypes=INSERTED,UPDATED,DELETED registers a durable queue, GET /event/subscription/{subscriptionId}?maxEvents= drains it, and a re-request by the previous requestId recovers a missed batch. Events carry the entity id, event type and changed property names, not payloads, so each is followed by an entity read.
Rate limitsNot published as a fixed figure in the REST reference; Bullhorn throttles per API client and answers HTTP 429 when the ceiling is hit (indicative until confirmed at onboarding). The BhRestToken inactivity timeout applies separately from the OAuth access token lifetime.
Readiness
Docs confidence: Public

“Docs confidence” describes how deterministic our mapping templates can be before we connect to a tenant. Even with public docs, implementations vary, especially around custom fields, approval flows and object extensions.

Deterministic mapping

Common Workforce Model fields map to known API fields. Best for standard objects (requisitions, assignments, timesheets, POs).

Tenant discovery

composerID can scan tenant configuration (custom fields, picklists, required fields) where the platform permits it, then generate a tenant‑specific MappingProfile.

Enrichment loop

If the target platform requires a field the Intent record doesn't yet have, composerID emits an enrichment_request back to the intake layer.

Mapping
Minimum viable mapping for Bullhorn

An opinionated baseline. The platform adapter enforces additional requirements via preflight. “Tenant required” fields are discovered during connection and added to the MappingProfile.

Object Canonical fields Platform target Required status Notes
JobOrder
Open the approved demand
role_title -> title, worker_type -> employmentType (Permanent, Contract, Contract To Hire per tenant picklist), start_date -> startDate, headcount -> numOpenings, clientCorporation{id}, clientContact{id}, description, salary or payRate PUT /entity/JobOrder Required clientCorporation and clientContact are tenant records resolved before publish; the full required set and picklist values come from GET /meta/JobOrder per tenant. The response carries changedEntityId, which composerID keeps for the deep link.
Intent ID correlation
Carry the Intent ID
intent_id -> externalID (String) on the JobOrder JobOrder.externalID, written on PUT /entity/JobOrder and read back with GET /query/JobOrder?where=externalID='{intent_id}'&fields=id,title,status Required externalID is a stock client-supplied reference on JobOrder, but the entity reference lists it as a short string (about 30 characters as we recall it), so a 64-character Intent ID may not fit: confirm the length and its use in a /query where clause at onboarding. If it is too short, or already used by another integration, a customText{n} field (String, 100 characters) with a renamed label is the documented fallback and is likewise queryable. Classic UI route {tenant_host}/BullhornSTAFFING/OpenWindow.cfm?Entity=JobOrder&id={id} (illustrative; the Novo UI has its own routes).
Placement
Read the fill
status, candidate{id}, dateBegin, dateEnd, payRate, clientBillRate, jobOrder{id} GET /query/Placement?where=jobOrder.id={id} or GET /entity/Placement/{id}?fields= Read scope only A Placement against the JobOrder is the executed engagement for both permanent and contract channels; tied back to the Intent ID through jobOrder.externalID.
Change feed
Follow status without polling every record
JobOrder, JobSubmission and Placement INSERTED / UPDATED events by entity id PUT then GET /event/subscription/{subscriptionId} Recommended Pull model: the subscription is drained on a schedule and each event is followed by an entity read of the named fields. No signed webhook exists to verify.
Idempotency & drift: publish + reconcileExpand

Publish operations are idempotent using a deterministic key {intent_id}-{intent_version}-{target_system}. Because humans can change records inside the platform, composerID supports reconciliation: it compares the platform record snapshot to the canonical intent and flags drift.

Tenant specifics
Custom fields & unique mapping

Real deployments rely on program-specific custom fields (for compliance, approvals, GL coding, rate rules or supplier constraints). composerID is designed to generate tenant‑specific mappings rather than forcing you to redesign your intake.

How scanning works

High-level flow

connect_destination() → read required fields + picklists (where permitted) → detect custom fields / extensions → build MappingProfile + validation rules → preflight intent against tenant requirements

What gets produced

Portable artefacts

MappingProfile (tenant-scoped) Capabilities matrix Required-field rules Picklist dictionaries Enrichment prompts Audit spine links (defence_file_ref)
Important: where this platform is tenant-definedExpand

API credentials are not self-service: the client_id and client_secret are issued by Bullhorn Support for a tenant holding the REST API licence (or through the Marketplace partner programme), and calls run as a named Bullhorn API user whose entitlements bound what the integration can see and write. The restUrl and corpToken differ per data centre and are discovered at login, so nothing is hardcoded. Field labels, required fields, picklists (employmentType, status) and the meaning of customText fields are tenant-configured: read GET /meta/JobOrder during preflight. Confirm at onboarding that externalID is free for composerID's use, long enough for the tenant's Intent IDs and usable in a /query where clause; otherwise reserve a customText field and record which one.

Next
Implement the adapter

Use this page alongside the API + Schemas docs to implement: destination connection, preflight validation, publish, webhook back-sync and reconciliation.