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
Legal entity fan-out
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.