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.
| Data | Owner | Direction |
|---|---|---|
| Jobs and their descriptions | Your ATS | ATS to ProBB AI |
| Candidates and their resumes | Your ATS | ATS to ProBB AI |
| Reports, scores and interview evaluations | ProBB AI | ProBB AI to ATS |
| Hiring decisions | Both | Both 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
- 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.
- Call
GET /api/v1/api-keyonce and save theid. You need it to recognize your own changes. - 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.updatedanddecision.cleared. - Save a place in the events feed before you start the first load. Read
GET /api/v1/events?types=…page by page untilhasMoreisfalse, and keep the lastnextCursor. 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.
- Jobs. For each open job, call
POST /api/v1/jobswithtitle,descriptionandexternalId. A409 job_existsmeans it was already pushed. The answer carriesexistingId. CallPATCHon that job instead. - Candidates. For each candidate, call
POST /api/v1/candidateswithexternalId, contact details and a resume. For a resume file, first callPOST /api/v1/files, thenPUTthe file to the address you get back, and passresume: { "fileId": "…" }. Plain text works too:resume: { "text": "…" }. A409 candidate_existsmeans a candidate with thatexternalIdor email is already there. CallPATCHonexistingId, or sendallowDuplicate: trueif two people really share an email. If duplicates did get in,POST /api/v1/candidates/{id}/mergewith{ "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-previewshows what would move first, and jobs have the same pair of endpoints. A merge answers409 merge_work_in_progresswhile Probb is still reading a resume or working on a record; repeating a finished merge returnsreplayed: true. - Decisions. For each candidate and job that already has a decision in your ATS, call
PUT /api/v1/jobs/{jobId}/candidates/{candidateId}/decision. - Anything ProBB AI already had. Page through
GET /api/v1/jobs,GET /api/v1/candidatesandGET /api/v1/decisionsand match each record to your own byexternalId, or by email for candidates. Records people added in the app have noexternalId.
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:
- Verify the signature. See webhooks.
- Skip it if you have seen its
idbefore. - Skip it if
actor.typeisapi_keyandactor.idis your key's ID. It is your own change coming back. - Find your record by the ProBB AI
id. Skip the event if your copy has a newerupdatedAtthandata.object.updatedAt. - Apply
data.object. It is the record asGETreturns it. On a*.updatedevent,previouslists 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 stage | ProBB AI decision |
|---|---|
| Moving forward | advance |
| On hold | hold |
| Rejected or withdrawn | not_progressing |
| Hired | hired |
| Back to unreviewed | no decision. Call DELETE on the decision. |
- Write:
PUT /api/v1/jobs/{jobId}/candidates/{candidateId}/decisionwithdecisionand an optionalnote. Repeating the same call changes nothing. - Clear:
DELETEon the same path. It answers404 decision_not_foundif 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 asdecision.cleared. If you reconcile by listing, treat a decision you hold that is missing from the full list as cleared. - History:
GET …/decision/historyshows 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.deletedorjob.deleted.candidate.deletedcarries only theidandexternalId, 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.deletedcarries the job as it was. - To delete from your side, call
DELETE /api/v1/candidates/{id}orDELETE /api/v1/jobs/{id}. This is permanent. By default it also deletes the reports, interviews and decisions for that record. Add?keepReports=trueto 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
externalIdand you store the ProBB AIidandrevision. - Every
POSThas anIdempotency-Key. - You skip events from your own key, and events you have already seen.
- You compare
updatedAtbefore you apply an old event. - You save the feed cursor and can rebuild from a full list.
- You treat
412and409as normal results, not crashes.