Bullhorn integration
Sync Bullhorn candidates, client corporations and placements into TimeTracking with external-ID upserts and batch endpoints, and push approved hours back with GET /days.
Last updated: · Markdown version
1. Summary
Status: Recipe (no native connector; works today with the TimeTracking REST API plus a small sync script or iPaaS scenario).
Direction: Bullhorn to TimeTracking for master data (candidates, client corporations, placements). Optional: TimeTracking to Bullhorn for approved hours.
| From Bullhorn | To TimeTracking |
|---|---|
Placed Candidate |
person (external_source bullhorn, external_ref = candidate id) |
ClientCorporation |
client (external_ref = corporation id) |
Placement (Approved / Active) |
placement (external_ref = placement id) |
| Placement terminated | placement deactivated (DELETE /placements/{id}) |
Optional back-sync: approved TimeTracking days to Bullhorn time records (only with Bullhorn Back Office or a timesheet module; see section 4).
2. Mechanism
- Bullhorn side. Read entities through the Bullhorn REST API (
/search/Placement,/entity/Candidate/{id},/entity/ClientCorporation/{id}). Detect changes with the Bullhorn event subscription API (/event/subscription/{name},PlacementINSERTED/UPDATED) or a scheduled query ondateLastModified. - TimeTracking side. Idempotent upserts by Bullhorn id:
PUT /clients/by-external/bullhorn/{corporationId}PUT /people/by-external/bullhorn/{candidateId}PUT /placements/by-external/bullhorn/{placementId}withpersonExternalRef+clientExternalRef- or the batch endpoints
POST /{clients|people|placements}/batch(up to 500 rows,externalSource+externalRefper row).
- Hours back.
GET /days?from&to&status=approved&updated_since=<last run>(cursor-paginated), triggered by thetimesheet.approvedwebhook or a schedule.
Assumption — verify with your Bullhorn admin/consultant: entity and field names follow the standard Bullhorn REST data model; your instance may keep employee numbers in custom fields (customText1…).
3. Prerequisites
- Bullhorn REST API credentials (OAuth client id/secret, API user) with read access to Candidate, ClientCorporation, Placement.
- A TimeTracking API key with scope
write(Regels & koppelingen → API-sleutels), stored asTT_API_KEY. - For webhooks (optional): an admin user or an
admin-scope API key to create them, and a public HTTPS receiver. - A place to store the last successful
updated_sincetimestamp for the back-sync. No ID map is needed any more.
4. Entity mapping
| Bullhorn object.field | TimeTracking field | Notes |
|---|---|---|
Candidate.id |
person external ref (/people/by-external/bullhorn/{id}) |
Stable key. |
| Payroll number (custom field) | employeeNumber |
Payroll exports key on it. If Bullhorn has none, the office fills it in TimeTracking; your upsert must then resend the stored value (full replace). |
Candidate.firstName / lastName |
firstName / lastName |
Required. |
Candidate.email |
email |
Lowercased. Needed for app access. |
Candidate.mobile |
phone |
Max 30 chars. |
ClientCorporation.id |
client external ref (/clients/by-external/bullhorn/{id}) |
|
ClientCorporation.name |
name |
|
ClientContact (timesheet approver) |
client contact | No API for contacts; add approvers in the dashboard (Opdrachtgevers). |
Placement.id |
placement external ref; also ref |
ref is shown to office staff and used in exports (placementRef). |
Placement.candidate.id |
personExternalRef |
Person must exist first (same source). |
Placement.jobOrder.clientCorporation.id |
clientExternalRef |
Client must exist first. |
Placement.jobOrder.title |
roleTitle |
Max 120 chars. |
Placement.dateBegin / dateEnd |
startsOn / endsOn |
Epoch ms in Bullhorn; convert to YYYY-MM-DD in the org timezone. Omit endsOn for open-ended. |
Placement.status Terminated/Completed |
DELETE /placements/{id} or active: false |
Deactivation emits placement.updated {placementId, active:false}. |
TimeTracking day (GET /days item) |
Bullhorn TimeCard / PlacementTimeUnit |
Assumption: only with Bullhorn Back Office / timesheet module. Key the Bullhorn record on the TimeTracking day id. |
| TimeTracking leave / expenses | not synced | They go to payroll through the export. |
External-ID strategy. Use source = bullhorn and the Bullhorn numeric id as ref on all three resources. Upserts are idempotent: repeat them freely. Every upsert is a full replace: send every field you want to keep; omitted optional fields become null. Responses include external_source, external_ref and created: true|false.
5. Step-by-step setup
5.1 Variables
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_... # write scope
5.2 Upsert a client, person and placement
curl -s -X PUT "$TT/clients/by-external/bullhorn/8812" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"name":"Logistiek Parkstad","externalRef":"8812","approvalMode":"client","mailDomains":"logistiekparkstad.example"}'
curl -s -X PUT "$TT/people/by-external/bullhorn/12345" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"employeeNumber":"9910","firstName":"Eva","lastName":"Docs","email":"eva.docs@example.com","locale":"nl"}'
curl -s -X PUT "$TT/placements/by-external/bullhorn/45120" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-H "Idempotency-Key: bh-pl-45120-v3" \
-d '{"personExternalRef":"12345","clientExternalRef":"8812","ref":"BH-45120","roleTitle":"Lasser","startsOn":"2026-12-01"}'
Real response of a person upsert (captured on the local API):
{"data":{"id":"69a47995-bc6a-4bc9-9b9b-070935b974bb","employee_number":"9910","first_name":"Eva","last_name":"Docs",
"email":"eva.docs@example.com","phone":null,"locale":"nl","active":true,
"external_source":"bullhorn","external_ref":"12345","created":true, "…":"…"}}
A placement whose person or client does not exist yet fails with 422 Person and client must exist (by id or external ref) before the placement.
5.3 Bulk load with batches
curl -s -X POST "$TT/people/batch" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"items":[
{"externalSource":"bullhorn","externalRef":"12346","employeeNumber":"9911","firstName":"Noah","lastName":"Docs"},
{"externalSource":"bullhorn","externalRef":"12347","firstName":""}]}'
{"data":{"ok":1,"failed":1,"results":[
{"index":0,"ok":true,"data":{"id":"02595da9-…","external_source":"bullhorn","external_ref":"12346","created":true,"…":"…"}},
{"index":1,"ok":false,"error":{"code":"invalid","message":"firstName: Too small: expected string to have >=1 characters; lastName: Invalid input: expected string, received undefined"}}]}}
Load in order: clients, then people, then placements. Rows run sequentially; one bad row never aborts the batch.
5.4 Sync script (TypeScript, Node 20+)
// bullhorn-to-tt.ts — run every 15 min or after polling a Bullhorn event subscription.
const TT = "https://app.timetracking.tech/api/v1";
const H = { authorization: `Bearer ${process.env.TT_API_KEY}`, "content-type": "application/json" };
async function tt<T>(method: string, path: string, body?: unknown, idemKey?: string): Promise<T> {
for (;;) {
const res = await fetch(TT + path, {
method, body: body ? JSON.stringify(body) : undefined,
headers: { ...H, ...(idemKey ? { "idempotency-key": idemKey } : {}) },
});
if (res.status === 429) { await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 60) * 1000)); continue; }
const json = await res.json();
if (!res.ok) throw new Error(`${method} ${path} ${res.status} ${JSON.stringify(json.error)}`);
return json.data as T;
}
}
interface BhPlacement {
id: number; status: string; dateBegin: number; dateEnd: number | null; payrollNumber?: string;
candidate: { id: number; firstName: string; lastName: string; email?: string; mobile?: string };
jobOrder: { title: string; clientCorporation: { id: number; name: string } };
}
const isoDate = (ms: number) => new Date(ms).toLocaleDateString("sv-SE", { timeZone: "Europe/Amsterdam" });
const row = (o: Record<string, unknown>) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null));
export async function syncPlacements(ps: BhPlacement[]) {
const corps = new Map(ps.map((p) => [p.jobOrder.clientCorporation.id, p.jobOrder.clientCorporation]));
const cands = new Map(ps.map((p) => [p.candidate.id, p]));
const check = (label: string, r: { failed: number; results: any[] }) =>
r.results.filter((x) => !x.ok).forEach((x) => console.error(label, x.index, x.error));
check("clients", await tt<any>("POST", "/clients/batch", { items: [...corps.values()].map((c) =>
({ externalSource: "bullhorn", externalRef: String(c.id), name: c.name })) }));
check("people", await tt<any>("POST", "/people/batch", { items: [...cands.values()].map((p) => row({
externalSource: "bullhorn", externalRef: String(p.candidate.id), employeeNumber: p.payrollNumber,
firstName: p.candidate.firstName, lastName: p.candidate.lastName, email: p.candidate.email, phone: p.candidate.mobile,
})) }));
for (const p of ps) {
const ended = ["Terminated", "Completed"].includes(p.status);
await tt("PUT", `/placements/by-external/bullhorn/${p.id}`, row({
personExternalRef: String(p.candidate.id), clientExternalRef: String(p.jobOrder.clientCorporation.id),
ref: `BH-${p.id}`, roleTitle: p.jobOrder.title.slice(0, 120),
startsOn: isoDate(p.dateBegin), endsOn: p.dateEnd ? isoDate(p.dateEnd) : undefined, active: !ended,
}));
}
}
If the office maintains employeeNumber, email or locale in TimeTracking, read the current record first (GET /people/{id} or the paginated list) and resend those values, because upserts are full replaces.
5.5 Optional: approved hours back to Bullhorn
curl -s "$TT/days?from=2026-11-30&to=2026-12-13&status=approved,exported&updated_since=2026-12-07T00:00:00Z&limit=500" \
-H "Authorization: Bearer $KEY"
Each item has id, placement_id, person_id, work_date, minutes, start_min, end_min, break_min, status, version, updated_at. Follow next_cursor until it is null. Map placement_id to the Bullhorn placement with GET /placements/{id} (external_ref), and upsert the Bullhorn time record keyed on the day id. Raw per-source claims are available with GET /claims?from&to&placement_id.
6. Sync schedule and triggering events
| Trigger | Action |
|---|---|
Bullhorn Placement INSERTED/UPDATED (event subscription, polled every 1–15 min) |
Run syncPlacements. |
| Nightly full run | Re-upsert all active placements (idempotent; repairs drift). |
TimeTracking timesheet.approved webhook, or every 15 min |
GET /days?status=approved&updated_since=<last run> and push to Bullhorn. |
placement.updated with active:false |
Optional: reflect a deactivation done by the office. |
export.completed |
Optional: mark the period as sent to payroll. |
person.* and placement.* events fire for external-ID upserts and deactivations. Webhooks arrive up to ~5 minutes after the event. See Webhook events.
7. Corrections, conflicts and locked periods
- Hours are owned by TimeTracking. Write hours into TimeTracking only if Bullhorn is your timesheet source; then use
POST /claimsorPOST /claims/batchwithsource: "import"andsourceRef= the Bullhorn time record id, plus a client-generated claimidfor retries. - Disagreeing sources create a conflict (
conflict.created). Resolve it in TimeTracking. - A correction after approval moves the day back to
submittedand bumpsversion/updated_at. Your back-sync overwrites by dayid; it never adds. - Exported and locked days never change (
423 locked). A late correction is a new entry in an open period. - Setting
endsOnbefore existing hours returns409(N registration(s) exist after <date>).
8. Testing checklist
- A new Bullhorn placement returns
created: true; the second run returnscreated: falseand no duplicates exist. - Changing the candidate's phone does not clear fields you resend; fields you omit are cleared (verify your script resends them).
- Terminating a placement deactivates it and emits
placement.updated{active:false}. - A batch with one invalid row reports
failed: 1and still writes the others. - Back-sync: approving a day appears in
GET /days?updated_since=…and in Bullhorn within ~15 min. - Retrying a
PUTwith the sameIdempotency-Keyreturns headeridempotent-replayed: true.
9. Troubleshooting
| Error | Cause | Fix |
|---|---|---|
401 "Invalid or revoked API key" |
Key wrong/revoked, or its creator lost admin rights | Create a new key as a current admin. |
403 "API key is read-only" |
Key has only read |
Use a write key. |
403 "Admin role required" |
Webhook management with a write key |
Use an admin-scope key or the dashboard. |
422 "Person and client must exist (by id or external ref) before the placement" |
Placement upserted before person/client, or different source |
Upsert clients and people first, with the same source. |
422 "Invalid cursor" |
Cursor reused across endpoints or edited | Restart paging without cursor. |
422 "Idempotency-Key was already used for a different request" |
Same key reused for another path | Derive keys from method + path + version. |
409 "N registration(s) exist after …" |
endsOn before existing hours |
Keep the later end date or correct hours. |
429 |
Over 600 requests/min | Wait Retry-After; use batches. |
Full list: Errors.
10. Limits and known gaps
- No API for client contacts (approvers).
person.*/placement.*/client.*payloads contain only the id ({personId},{placementId},{clientId}, plusactive:falseon deactivation); fetch details withGET /{resource}/{id}.- Batch rows run sequentially; 500 rows can take several seconds.
- Native Bullhorn Marketplace app with OAuth2: Planned.
11. Build it with Claude
Build a Bullhorn → TimeTracking placement sync in TypeScript (Node 20).
Bullhorn: REST API, OAuth login, read Placement (status, dateBegin, dateEnd, candidate, jobOrder.title,
jobOrder.clientCorporation) changed since the last run (dateLastModified), plus Candidate firstName, lastName,
email, mobile and the payroll-number custom field. Make field names configurable.
TimeTracking: base https://app.timetracking.tech/api/v1, header Authorization: Bearer $TT_API_KEY, responses
{ data } or { error: { code, message } }. Use idempotent upserts by external id with source "bullhorn":
PUT /clients/by-external/bullhorn/{corpId} {name, approvalMode},
PUT /people/by-external/bullhorn/{candidateId} {employeeNumber, firstName, lastName, email, phone, locale},
PUT /placements/by-external/bullhorn/{placementId} {personExternalRef, clientExternalRef, ref, roleTitle,
startsOn, endsOn, active}. Upserts are FULL replaces. For bulk loads use POST /{clients|people}/batch
{items:[{externalSource, externalRef, …}]} (≤500, per-row results). Deactivate with DELETE /placements/{id}.
Send Idempotency-Key on every write, handle 429 (Retry-After) and 409/422 (log, no retry).
Back-sync: GET /days?from&to&status=approved&updated_since=<checkpoint>&limit=500, follow next_cursor,
write Bullhorn time records keyed on day id. Deliver the script, a dry-run flag and tests with mocked HTTP.