# 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.

URL: https://timetracking.tech/developers/integrations/ats-crm/bullhorn
Last updated: 2026-10-24

## 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

```sh
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_...   # write scope
```

### 5.2 Upsert a client, person and placement

```sh
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):

```json
{"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

```sh
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":""}]}'
```

```json
{"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+)

```ts
// 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

```sh
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](https://timetracking.tech/developers/webhooks/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](https://timetracking.tech/developers/api/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

```text
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.
```
