Docs

Keep an ATS in sync

Mirror jobs, candidates and hiring decisions between your applicant tracking system and ProBB AI, in both directions.

This guide shows how to keep your ATS and ProBB AI in step. It covers the first load, the changes after it, the hiring decisions, and deletes. It assumes you have read API concepts.

Plan who owns what

Decide this first. It prevents most sync bugs.

DataOwnerDirection
Jobs and their descriptionsYour ATSATS to ProBB AI
Candidates and their resumesYour ATSATS to ProBB AI
Reports, scores and interview evaluationsProBB AIProBB AI to ATS
Hiring decisionsBothBoth ways

Give every job and candidate your own ID in externalId, for example greenhouse:123. It is how you match records in both directions.

Set up

  1. Create a key with the Sync only preset. It reads and writes candidates, jobs and decisions, reads webhooks and the organization, and cannot spend credits. See authentication.
  2. Call GET /api/v1/api-key once and save the id. You need it to recognize your own changes.
  3. Add a webhook endpoint in Settings, Developer, Webhooks. The Sync only key can read endpoints but not create them. Subscribe it to candidate.created, candidate.updated, candidate.deleted, job.created, job.updated, job.closed, job.reopened, job.deleted, decision.updated and decision.cleared.
  4. Save a place in the events feed before you start the first load. Read GET /api/v1/events?types=… page by page until hasMore is false, and keep the last nextCursor. Everything after it is a change you have not seen.

First load

Push what is in your ATS, then pull what is already in ProBB AI.

  1. Jobs. For each open job, call POST /api/v1/jobs with title, description and externalId. A 409 job_exists means it was already pushed. The answer carries existingId. Call PATCH on that job instead.
  2. Candidates. For each candidate, call POST /api/v1/candidates with externalId, contact details and a resume. For a resume file, first call POST /api/v1/files, then PUT the file to the address you get back, and pass resume: { "fileId": "…" }. Plain text works too: resume: { "text": "…" }. A 409 candidate_exists means a candidate with that externalId or email is already there. Call PATCH on existingId, or send allowDuplicate: true if two people really share an email. If duplicates did get in, POST /api/v1/candidates/{id}/merge with { "mergeIds": [...] } folds them into the one you keep (reports, decisions and interviews move across; the duplicates are deleted and it cannot be undone). POST /api/v1/candidates/{id}/merge-preview shows what would move first, and jobs have the same pair of endpoints. A merge answers 409 merge_work_in_progress while Probb is still reading a resume or working on a record; repeating a finished merge returns replayed: true.
  3. Decisions. For each candidate and job that already has a decision in your ATS, call PUT /api/v1/jobs/{jobId}/candidates/{candidateId}/decision.
  4. Anything ProBB AI already had. Page through GET /api/v1/jobs, GET /api/v1/candidates and GET /api/v1/decisions and match each record to your own by externalId, or by email for candidates. Records people added in the app have no externalId.

Pace yourself. A key can write 120 times a minute, and a workspace 240. At most 100 uploaded files can wait to be attached at once. Send an Idempotency-Key with every POST, so a retry after a timeout cannot create a second record. A 429 carries Retry-After: wait that many seconds.

Store the ProBB AI id and revision next to each of your records. You will need them for every update.

Keep it in sync

Changes from your ATS

When something changes in your ATS, PATCH the matching record with If-Match: <revision>. If someone changed it in ProBB AI first you get 412 revision_mismatch. Read the record again, merge, and retry.

Mirror job status with POST /api/v1/jobs/{id}/close and POST /api/v1/jobs/{id}/reopen. A closed job takes no new reports.

Changes from ProBB AI

Webhooks are the fast path. For each event:

  1. Verify the signature. See webhooks.
  2. Skip it if you have seen its id before.
  3. Skip it if actor.type is api_key and actor.id is your key's ID. It is your own change coming back.
  4. Find your record by the ProBB AI id. Skip the event if your copy has a newer updatedAt than data.object.updatedAt.
  5. Apply data.object. It is the record as GET returns it. On a *.updated event, previous lists the old values of the fields that changed.
async function handleEvent(event: ProbbEvent) {
  if (await seen(event.id)) return;
  if (event.actor.type === "api_key" && event.actor.id === MY_KEY_ID) return;

  const record = event.data.object;
  switch (event.type) {
    case "job.created":
    case "job.updated":
    case "job.closed":
    case "job.reopened":
      return upsertJob(record);
    case "candidate.created":
    case "candidate.updated":
      return upsertCandidate(record);
    case "decision.updated":
      return setAtsStage(record.candidate.id, record.job.id, record.decision);
    case "decision.cleared":
      return clearAtsStage(record.candidate.id, record.job.id);
    case "job.deleted":
      return archiveJob(record.id);
    case "candidate.deleted":
      return archiveCandidate(record.id);
  }
}

Do not skip system events. When ProBB AI reads a resume it fills in the profile, and when it reads a job it fills in the company, team and location. Those arrive as candidate.updated and job.updated from system. Decide which fields your ATS accepts and ignore the rest.

Catch up on what you missed

Your server will be down sometimes. Webhooks are retried for about a day, but do not rely on that alone. Every few minutes, or after downtime, read the feed from your saved cursor:

curl "$PROBB_URL/api/v1/events?after=$CURSOR&limit=100" \
  -H "Authorization: Bearer $PROBB_API_KEY"

Process each event in order, save nextCursor after each page, and stop when hasMore is false. The feed uses the same events as webhooks, so one handler serves both. The feed keeps 30 days. A cursor older than that returns 410 event_cursor_expired. Then run a full resync: list with updatedSince set to the last time you were sure you were up to date, or without it to start again.

As a second safety net, once a day list GET /api/v1/jobs?updatedSince=… and GET /api/v1/candidates?updatedSince=…. These lists run oldest change first. Apply any record whose updatedAt is newer than your copy.

Hiring decisions

A decision belongs to a candidate and a job. Map your ATS stages to the four values once and keep the mapping in one place.

Your ATS stageProBB AI decision
Moving forwardadvance
On holdhold
Rejected or withdrawnnot_progressing
Hiredhired
Back to unreviewedno decision. Call DELETE on the decision.
  • Write: PUT /api/v1/jobs/{jobId}/candidates/{candidateId}/decision with decision and an optional note. Repeating the same call changes nothing.
  • Clear: DELETE on the same path. It answers 404 decision_not_found if there was nothing to clear.
  • Read: GET /api/v1/decisions?updatedSince=… lists current decisions. It does not list cleared ones, so a clear only reaches you as decision.cleared. If you reconcile by listing, treat a decision you hold that is missing from the full list as cleared.
  • History: GET …/decision/history shows every change and who made it.

A decision does not need a report. You can record one for any candidate and job.

The AI interview has its own recommendation. It is advice for your recruiter, not a decision. Do not copy it into the decision.

Deletes

  • A person or job deleted in the app reaches you as candidate.deleted or job.deleted. candidate.deleted carries only the id and externalId, not the person's details, and the earlier events about that candidate are emptied, so a feed read after the deletion no longer shows them. Archive or remove your copy; job.deleted carries the job as it was.
  • To delete from your side, call DELETE /api/v1/candidates/{id} or DELETE /api/v1/jobs/{id}. This is permanent. By default it also deletes the reports, interviews and decisions for that record. Add ?keepReports=true to keep the reports and detach them. Credits spent are never refunded.
  • Closing a job is not a delete. Use close and reopen to follow your requisition status.

Checklist

  • Every record has an externalId and you store the ProBB AI id and revision.
  • Every POST has an Idempotency-Key.
  • You skip events from your own key, and events you have already seen.
  • You compare updatedAt before you apply an old event.
  • You save the feed cursor and can rebuild from a full list.
  • You treat 412 and 409 as normal results, not crashes.

On this page