Skip to main content

Implementation Overview

Implementation Type: Kombo-based Integration (open beta connector) Note Format: Plain Text Status: Read-only Paycor is integrated through Kombo’s unified API, the same way as most of our other ATS integrations (Bullhorn, Jobvite, Greenhouse, Workday, etc.). Kombo’s Paycor connector is currently in open beta, and Paycor’s own underlying ATS API is too thin to trust for write-back — so, unlike other Kombo-backed integrations, this integration is strictly read-only. Tenzo syncs jobs, candidates, applications and (natively, via passthrough) application stages out of Paycor and runs the normal AI screening/interview flow, but never writes anything back into Paycor.

Configuration

Kombo returns one ATS entity per Paycor legal entity sharing an ATS account. The fan-out is encoded in the remote id:
Because our sync dedups on Kombo’s id, which differs per legal entity, one real job can become several campaigns rows and one real person several candidates rows — each of which can be called and texted independently. This is permanent, not a bug awaiting an upstream fix. Paycor’s API is legal-entity-scoped (/v1/legalEntities/{id}/ats/{account}/...), so Kombo needs the prefix to route getCandidate / createCandidate. Our own server/ats/paycor/paycor_ats_client.py does exactly the same.

How we collapse the fan-out

PaycorAtsClient pins one legal entity per atsAccountId:atsJobId and drops every other leg at the client boundary, so a real Paycor job produces exactly one campaign and its candidates are ingested once. The pin needs no new storage. Campaign.ats_job_remote_id already holds the full legalEntityId:atsAccountId:atsJobId, so on first use of a sync the client derives the map {atsAccountId:atsJobId -> legalEntityId} from the org’s existing campaigns and extends it in memory as new jobs stream past. Because the map is re-derived from campaigns every sync, a job that has a campaign is pinned to the same leg every time — that is what makes the collapse idempotent. A job that does not have a campaign yet has nothing to re-derive from. Its leg is decided by whichever copy the job stream yields first, and that becomes the campaign, after which the pin is stable. Candidates and applications for such a job fall back to a separate candidate-only map so their copies still collapse to one within a run; that fallback is deliberately never written into the job pins, because the job and applicant streams can share one client and a candidate-derived leg there could make the job stream drop the leg that should have won. The filter applies to the streaming sync paths only — jobs (_stream_jobs), candidates (_stream_candidates) and, defensively, applications (_stream_applications). Records that survive keep their untouched composite remote_id, so every native/passthrough read still has the legal entity it needs to address Paycor. A remote_id that does not parse is kept and warned about rather than dropped, and every skipped duplicate is logged with both legal entity ids. Fetch-by-id (get_job_by_job_id, get_jobs_by_job_ids) is deliberately not filtered. Those methods answer “give me this exact job”, and dozens of read paths — outreach eligibility, notes context, SMS job messaging, the public application API — call them with a campaign’s own ats_integration_job_id. Substituting a sibling leg there would answer a different question than the caller asked. The consequence is that manually syncing a job by id on a non-pinned leg can still create a duplicate campaign; that is a deliberate single-job action rather than the automatic fan-out this change targets. Kombo also exposes a per-connection “Legal entity IDs to sync” setting — Alpine Intel’s connection is set to 190521,63178. That is still worth curating at onboarding to cut wasted API traffic, but it is no longer the only defense: an uncurated connection now dedups in our code instead of fanning out. We never call Kombo’s integration-settings API, so that setting remains invisible to our code and can only be verified by observing a sync.
Limitation: if the pinned legal entity is later removed from the Kombo connection, that job stops appearing even though other entities still expose it, because its campaign still names the dead entity. Recovery is clearing that campaign’s ats_job_remote_id (or deleting the campaign); the next sync then adopts a surviving leg.

Diagnosing a fan-out

Group an org’s candidates by the legal-entity prefix. A healthy connection shows only the curated entity ids:

Cleaning up rows that already exist

Client-side pinning stops new duplicates; it cannot touch rows already written. Those are retired by server/one-time-jobs/cleanup_paycor_legal_entity_duplicates.py:
It is dry-run by default (--apply to write). A duplicate is deleted only when the sole rows hanging off it are the ATS-derived projections the next sync regenerates on the survivor (resume, experiences, attribute assignments, resume scores). Anything else — a call, a text, an application, an interview, a placement, an intake link — counts as history: that row is reported for hand-merging instead, as is every campaigns row. The probe is derived from the delete plan itself, so no table can be deleted without first being checked. Deleted rows are copied into zz_paycor_retire_backup_* tables first; those snapshots are the only undo.

Application stages

Kombo’s unified API returns no stage for Paycor applications (current_stage is null), so Tenzo reads the stage natively. Paycor’s candidate records carry the hiring stage as a small integer code (stage, alongside a status sub-code and startingStage/startingStatus), and Paycor exposes no endpoint listing the stages. PaycorAtsClient walks each job’s native candidate list (/v1/legalEntities/{le}/ats/{account}/jobs/{job}/candidates) through Kombo passthrough — one page-walk per job per sync, shared by every application on that job — and attaches the stage to each application before the start-stage gate, the cached application_stage, disposition detection and outbound eligibility see it. Fetch-by-id paths carry the stage too. A failed fetch leaves the stage empty (no gate) at sync time and reports the calling stage as unknown (outreach defers) at eligibility time. Stage identity is the code itself (id/remote_id = "7"); the display name comes from PAYCOR_STAGE_NAMES in server/ats/paycor/paycor_models.py. Paycor does not publish the code → name table, so until it is confirmed, stages render as Stage 1, Stage 7, etc. Observed so far: codes 1, 2, 4, 5, 6, 7, 8 and account-specific 100/101; Kombo maps 6 → hired and 7 → declined. Because Start Stages matches on the rendered name, filling in a name later means re-selecting that stage in the org’s settings. The Start Stages dropdown offers the standard codes 1–8 plus any code already cached on the org’s synced candidates. Stage moves remain blocked — no stage-target field is configurable.

What’s supported

  • Streaming/reading jobs, candidates, and applications via Kombo’s unified API.
  • Reading each application’s hiring stage code from Paycor’s native per-job candidate list through Kombo passthrough, and gating outreach on the configured Start Stages.
  • Reading a candidate’s primary resume as HTML text from Paycor’s native candidate-detail endpoint through Kombo passthrough. Tenzo converts the HTML to plain text for resume review.
  • Dynamically reading Paycor-specific job attributes through Kombo passthrough and exposing them as job custom fields in the Rules Engine. Current examples include the ATS account ID and title, activation date, openings, priority, target time to fill, and per-channel posting flags. New Paycor-native fields are discovered from the native job payload, while fields already returned by Kombo’s normalized job model are not duplicated.

What’s explicitly blocked

Kombo’s Paycor connector does not support notes, binary attachments, stage writes, or disposition reasons at all. While Kombo does technically support creating candidates/applications and moving stages for Paycor, Tenzo does not use any of that — every write path is explicitly no-op’d rather than left to inherit the default Kombo-backed behavior:
  • Creating applications or candidates
  • Moving an application to a stage
  • Rejecting an application (single or bulk)
  • Adding notes to an application or candidate
  • Adding attachments to an application or candidate, or listing/downloading binary attachments
  • Writing custom fields (not exposed by Kombo for Paycor)
These no-op rather than raise, so the call-processing pipeline is never interrupted by a Paycor org’s write path being invoked.