Skip to content

Request an integration

Tell us which system you want to connect. We review every request weekly and publish a step-by-step guide or build the connection.

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}, Placement INSERTED/UPDATED) or a scheduled query on dateLastModified.
  • 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} with personExternalRef + clientExternalRef
    • or the batch endpoints POST /{clients|people|placements}/batch (up to 500 rows, externalSource + externalRef per row).
  • Hours back. GET /days?from&to&status=approved&updated_since=<last run> (cursor-paginated), triggered by the timesheet.approved webhook 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 as TT_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_since timestamp 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 /claims or POST /claims/batch with source: "import" and sourceRef = the Bullhorn time record id, plus a client-generated claim id for retries.
  • Disagreeing sources create a conflict (conflict.created). Resolve it in TimeTracking.
  • A correction after approval moves the day back to submitted and bumps version/updated_at. Your back-sync overwrites by day id; it never adds.
  • Exported and locked days never change (423 locked). A late correction is a new entry in an open period.
  • Setting endsOn before existing hours returns 409 (N registration(s) exist after <date>).

8. Testing checklist

  • A new Bullhorn placement returns created: true; the second run returns created: false and 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: 1 and still writes the others.
  • Back-sync: approving a day appears in GET /days?updated_since=… and in Bullhorn within ~15 min.
  • Retrying a PUT with the same Idempotency-Key returns header idempotent-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}, plus active:false on deactivation); fetch details with GET /{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.