Overview
This document explains why we do things differently for each ATS and highlights important implementation quirks you should know about.
Kombo-Based vs Direct API
Kombo-Based Integrations
ATS Systems: Greenhouse, Jobvite, Bullhorn, Crelate, JobDiva, Avionte, SuccessFactors, Workday, UKG Ready, Workable, Paradox (open beta)
How it works:
- We use Kombo’s unified API as an abstraction layer
- Kombo handles auth, rate limiting, and API differences
- We inherit most methods from
BaseKomboAtsClient
- Custom implementations only needed for ATS-specific features
Advantages:
- ✅ Faster to implement
- ✅ Automatic handling of API changes
- ✅ Consistent error handling
- ✅ Built-in retry logic
Disadvantages:
- ⚠️ Limited to what Kombo supports
- ⚠️ Can’t access ATS-specific advanced features easily
- ⚠️ Debugging requires understanding both our code and Kombo’s
Kombo HTTP 400 retries
Kombo sits between Tenzo and the customer’s ATS (SuccessFactors, Workday, Bullhorn, etc.). When the upstream ATS is temporarily unreachable, Kombo sometimes responds with HTTP 400 and a body like REMOTE.UNKNOWN_HTTP_ERROR / connect ECONNREFUSED … — even though the failure is a transient connectivity blip, not a malformed request from Tenzo.
Standard HTTP semantics treat 400 as permanent (bad request). Kombo does not consistently use 502/503 for these upstream failures, so we cannot rely on status codes alone to decide whether to retry.
What we do in the ATS request queue:
retry_on_400 defaults to off for most providers.
- Kombo-backed integrations opt in (
KOMBO_RATE_LIMIT_CONFIG): when enabled, the queue retries every HTTP 400 up to the provider retry budget, then returns the final response.
- Tracker also opts in: MW Resources Workable-via-Kombo job sync calls Kombo’s API but routes through
tracker: integration queues, so Tracker’s queue config enables the same 400 retry behavior.
- This is intentionally broad: real Kombo validation 400s may be retried a few times before surfacing, which is acceptable on Kombo-only traffic in exchange for surviving transient upstream errors (e.g. KwikTrip stage moves failing on a single ECONNREFUSED-as-400).
- Other 4xx (401, 404, 422, …) are never retried. 429 and 5xx behavior is unchanged.
Kombo per-connection entity scoping (Paycor)
Kombo can return the same real job or candidate more than once when the upstream ATS partitions data by a tenant-side scope that Kombo encodes into the remote id. Paycor is the case we hit: one entity per legal entity sharing an ATS account, prefixed as <legalEntityId>:<atsAccountId>:…. Our sync dedups on Kombo’s id, so each prefix became its own campaigns / candidates row and the same person could be contacted repeatedly.
The fix is client-side pinning: PaycorAtsClient picks one legal entity per atsAccountId:atsJobId — derived from the remote ids already stored on existing campaigns, so a job that has a campaign resolves to the same leg every sync — and drops the other legs as jobs, candidates and applications stream past. An uncurated connection therefore dedups in code rather than fanning out. A job with no campaign yet is pinned by whichever copy streams first, which then becomes its campaign.
Kombo’s per-connection “Legal entity IDs to sync” setting is still worth curating at onboarding to cut wasted API traffic, but it is no longer the only defense. It remains invisible to us, since the only per-connection scoping we send is the X-Integration-Id header. See Paycor ATS for the full mechanism, its limitation, the diagnostic query and the cleanup job.
Direct API Integrations
ATS Systems: ERecruit, RecruitCRM, Tracker, TempWorks, LiveHire, Public API
How it works:
- Direct HTTP calls to ATS provider’s API
- We handle all auth, pagination, rate limiting ourselves
- Complete control over all features
Advantages:
- ✅ Access to all ATS features
- ✅ Full control over implementation
- ✅ Can optimize for specific use cases
Disadvantages:
- ⚠️ More code to maintain
- ⚠️ Need to handle API changes ourselves
- ⚠️ More complex error handling
ATS-Specific Quirks
ERecruit
Customer-Specific API: Agilus (the customer) built a custom API layer on top of eRecruit. Our integration ONLY works with Agilus’s custom endpoints, not standard eRecruit.
Key Differences:
- Uses TWO separate auth tokens: one for eRecruit, one for Agilus custom API
- Most operations go through Agilus custom endpoints (
https://webapi.myagilus.ca/api)
- Standard eRecruit API missing critical functionality
- Heavy use of custom candidate application status tracking
Not Supported:
- ❌ All custom fields (returns empty or NotImplementedError)
- ❌ Get all application stages
- ❌ Notes to candidates
- ❌ Attachments to candidates
- ❌ Find candidates by details
Why: ERecruit’s standard API is very limited. Agilus built custom endpoints to fill gaps.
RecruitCRM
Key Differences:
- Uses “hiring pipelines” instead of traditional application stages
- Each job can have its own pipeline with custom stages
- Candidate-job associations are “assignments” not “applications”
- Rich candidate history tracking across multiple jobs
Not Supported:
- ❌ Formal rejection with reasons (API doesn’t expose rejection endpoint)
- ❌ Custom field schemas (can’t fetch field definitions dynamically)
- ❌ Find candidates by details
Workarounds:
- Move to “declined” stage instead of formal rejection
- Applications can still move through hiring pipelines
Why Different: RecruitCRM uses a pipeline-based workflow model rather than traditional ATS stages.
Tracker
Key Differences:
- Timezone Issues: Tracker returns local time instead of UTC
- We subtract 8 hours from
updated_after timestamps
- This is a quirk of their API returning PST/PDT times
- Uses category-based job organization
- Custom workflow states per job
Not Supported:
- ❌ Reject application (NotImplementedError)
- ❌ Get all application stages (doesn’t have unified stages)
Why Different: Tracker is built for staffing agencies with complex workflows, not traditional recruiting.
Bullhorn (Kombo)
Key Differences:
- Uses “Placement” records for hired candidates (different from applications)
- Complex entity relationships (Candidate → JobSubmission → Placement)
- Rich custom field support through Kombo passthrough
Gotchas:
- Application IDs are actually JobSubmission IDs
- Some operations require knowing the “corp” ID
- Heavy use of custom object types
Why Different: Bullhorn is enterprise staffing software with a complex data model for temp/contract workers.
JobDiva (Kombo)
Key Differences:
- Uses hardcoded custom field names (can’t fetch schemas)
- Special “Tenzo Active” field to filter jobs
- Req ID stored in
job_code field, not standard location
- Custom rejection reason endpoint via Kombo passthrough
Hardcoded Custom Fields:
Not Supported:
- ❌ Attachments (application & candidate) - NotImplementedError
- ❌ Dynamic custom field schema fetching
Why Different: JobDiva API doesn’t expose custom field definitions. We have to hardcode field names based on customer configuration.
Avionte (Kombo)
Key Differences:
- Application custom fields NOT available through Kombo
- Candidate custom field UPDATES not working (API restrictions)
- Uses “talent tags” for candidate categorization
Not Supported:
- ❌ Application custom fields (Avionte doesn’t expose via API)
- ❌ Update candidate custom fields (API path changes not allowed)
Why Different: Avionte has restricted API endpoints for custom field modifications.
SuccessFactors (Kombo)
Key Differences:
- OData-based API (XML metadata schemas)
- Custom field discovery through OData $metadata endpoint
- Uses SAP SuccessFactors recruiting module
- HTML note format
Not Supported:
- ❌ Notes to candidates (NotImplementedError)
Special Features:
- ✅ Can parse OData metadata to discover custom fields dynamically
- ✅ Supports customString*, customNumber*, customDate* field patterns
Why Different: SAP SuccessFactors uses OData protocol (enterprise standard) instead of REST.
Crelate (Kombo)
Key Differences:
- Uses Kombo passthrough for job batching (direct Crelate API)
- Application streaming uses Crelate-specific filtering; candidate streaming enforces a native candidate-only boundary
- Strong job custom field support
- Notes supported but attachments not yet implemented
Not Supported:
- ❌ Application custom fields (Crelate doesn’t have these)
- ❌ Bulk reject (NotImplementedError)
Why Different: Crelate is newer to Kombo, still building out full integration.
TempWorks (Direct API)
The existing Surge extension is operational. The TEN-934 standard TempWorks job and application code is implemented, but it must remain disabled until the target tenant’s credentials and read contracts are validated.
Current Surge extension:
- Incremental employee/candidate streaming into one configured Tenzo job
- Employee detail, resume, note, and attachment operations
- Employee interest-code and hire-status operations
- Assignment caching and Surge placement communication behavior
- Job Order detail lookup
Standard TempWorks flow implemented in TEN-934:
- Job Orders as jobs
- Job Order Candidates as applications
- Job Order Candidate statuses as application stages
- Active Employee and Job Order custom-field definitions, plus Job Order custom-field values
- Application creation for an existing Employee, status writeback to the exact candidacy, and Employee and Job Order custom-field writeback
The bounded read paths have been verified against Surge production, including job and candidacy searches, direct detail reads, all 21 configured application statuses, 35 active Employee custom fields, 21 active Job Order custom fields, and 20 values on a sampled Job Order. Candidacy status movement is implemented and unit tested, but no live status write has been made. Employnet validation remains pending its credentials, so its job and applicant sync must stay disabled.
TempWorks exposes one overwriteable candidacy comment and no candidacy-level document endpoint. The standard flow therefore stores interview summaries in Employee Contact Messages and interview PDFs in Employee documents, where recruiters can see them across that person’s candidacies.
Application custom-field reads and writes remain unsupported.
See TempWorks ATS for the two routing modes, rollout gates, and full limitations.
LiveHire (Direct API)
Early Stage: Humanforce Talent (LiveHire) direct Open API integration. Sync is webhook-first with applicant polling for Tenzo-synced jobs.
Key Differences:
- Direct OAuth client-credentials API (not Kombo)
- Jobs and candidates sync primarily via subscription webhooks; applicants for synced jobs are also polled on a short cron
includes_jobs_in_scheduled_sync=False — keep job_sync_enabled on for webhook/manual processing, but cron skips full job queue publish (LiveHire list API has no updated_after)
- Stages and disposition reasons come from recruitment-process configuration statuses (not a separate stage catalog)
create_application assigns an existing LiveHire candidate to a job vacancy; Tenzo does not create new LiveHire candidates
- Notes, attachments, stage moves, and rejections attribute writes to a recruiter email resolved from the Campaign
What Works:
- ✅ Stream jobs / applications / candidates (events + vacancy application lists)
- ✅ Move to stage / reject with disposition reasons
- ✅ Create application (assign existing candidate)
- ✅ Notes and PDF attachments (application and candidate)
- ✅ Resume fetch (candidate CV only for now — see caveat below)
- ✅ Job custom-field catalog (discovered from enhanced job cache; no LiveHire definitions API)
What Doesn’t Work:
- ❌ Custom field writeback
- ❌ Create new candidates in LiveHire
- ❌ Job additional-fields enrichment for rules (analytics) — not implemented yet; LiveHire permissions for
/analytics/job-vacancies-additional-fields are missing
- ⚠️ Application CV download (
GET /job-applications/{id}/cv/contents) is disabled in code: Challenger credentials get IAM 403. We use candidate CVs only until LiveHire grants that scope.
Why Different: LiveHire centers on talent communities and vacancy assign rather than Tenzo creating remote candidates. Analytics additional-field enrichment is deferred until the correct LiveHire permissions are available.
Public API (Minimal)
Key Differences:
- NOT a traditional ATS - it’s a webhook-based integration
- Jobs stored in
AtsJobCache cosmos DB
- No streaming (jobs pushed via API)
- Sends interview results via webhooks
What Works:
- ✅ Get job by ID (from cache)
- ✅ Create application (webhook trigger)
- ✅ Get candidate (temporary storage during processing)
What Doesn’t Work:
- ❌ Everything else (returns empty or minimal data)
Why Different: This isn’t an ATS integration - it’s for customers using our API as their “ATS.”
Tracker Workable (Hybrid)
Key Differences:
- Customer-specific hybrid integration (MW Resources only)
- Combines Tracker AND Workable data
- Uses Tracker for jobs, Workable for applications
- Special field mapping between systems
Why Different: MW Resources uses Tracker for job management but Workable for candidate applications. We bridge both systems.
Common Patterns
Custom Field Handling
Four approaches across integrations:
-
Dynamic Schema (Best): SuccessFactors, Greenhouse, Bullhorn, Workable
- Fetch field definitions from API
- Map field types automatically
- Works with any custom field
- Workable: Kombo unified job fields + SPI v3 passthrough for candidate custom-attribute writeback
-
Hardcoded: JobDiva
- Field names hardcoded in client
- Must match customer’s configuration
- Fragile but works when API doesn’t expose schemas
-
Cache discovery: UKG Ready, LiveHire
- No definitions API (or none used for jobs)
- Job catalog for rules is built by unioning
custom_field_values from the enhanced job cache
- A field appears in Conditions only after at least one synced job has carried it
- LiveHire: job writeback still unimplemented; UKG Ready: job writeback not supported
-
Not Implemented: ERecruit, RecruitCRM, TempWorks, Avionte (partial)
- API doesn’t support or too complex
- Returns empty or raises NotImplementedError
Rejection Handling
Two patterns:
-
Formal Rejection: Greenhouse, Bullhorn, Jobvite, LiveHire, etc.
- Get disposition reasons from ATS
- Reject with specific reason ID
- ATS tracks rejection separately from stage
-
Stage-Based: RecruitCRM, Tracker
- No formal rejection API
- Move to “declined” or “rejected” stage instead
- Workaround when ATS doesn’t have rejection concept
- Plain Text: ERecruit, RecruitCRM, Tracker, TempWorks, LiveHire
- HTML: Greenhouse, Bullhorn, SuccessFactors, Jobvite
Always check ats_note_format property before creating notes.
Debugging Tips
Kombo Integrations
- Check Kombo dashboard for API errors
- Use passthrough for ATS-specific calls
- Remember Kombo returns standardized models - may lose ATS-specific data
Direct API Integrations
- Check access token expiration
- Look for rate limiting (429 errors)
- Verify pagination cursor handling
- Check custom API base URLs (some customers have unique endpoints)
Common Issues
“Custom fields not saving”
- Check if ATS supports custom field updates
- Verify field type mapping is correct
- Some ATS require specific field formats
“Can’t reject applications”
- Check if ATS has formal rejection (many don’t)
- Use stage movement as fallback
- Verify disposition reasons are fetched correctly
“Streaming returns no results”
- Check
updated_after timestamp format (ISO 8601)
- Some ATS use local time instead of UTC (Tracker!)
- Verify job status filters (some ATS hide closed jobs)