Developer Quickstart: Building a Multi-ATS Link UI for Greenhouse, Lever & More
A developer quickstart for integrating Greenhouse, Lever, and Workable simultaneously - with auth patterns, pagination translation, webhook verification, reconciliation jobs, and debugging tips.
Your sales team just closed a verbal commit with a Series C HR tech buyer. The catch: they use Lever, the next prospect in your pipeline uses Greenhouse, and the enterprise lead from last month uses Workable. Your engineering lead estimates four to six weeks per applicant tracking system (ATS) for the bespoke OAuth flows, custom field mappings, settings UIs, and webhook plumbing. That is twelve to eighteen weeks of senior engineering work before a single candidate record syncs. The deal is effectively stalled before the ink is dry.
The ATS market is structurally highly fragmented, making point-to-point integrations unscalable. The global applicant tracking system market is anticipated to rise from about USD 3.28 billion in 2025 to USD 4.88 billion by 2030, growing at a CAGR of 8.2% during the forecast period. That growth is spread across hundreds of niche and enterprise vendors. Your enterprise prospects use Greenhouse and Workday Recruiting. Your scale-up prospects use Lever and Ashby. Your SMB customers use Workable, Recruitee, JazzHR, and Teamtailor. There is no single ATS to integrate with—there is a long tail you have to support to win deals.
Building SaaS integrations in-house is a massive capital expense. Developing a mid-scale SaaS product with third-party integrations typically costs between $60,000 and $150,000 to build, with ongoing maintenance adding 15-20% annually. That number balloons fast once you account for token-refresh fires, schema drift, and the inevitable undocumented API quirks.
This quickstart is a technical blueprint for collapsing that months-long integration nightmare into a single afternoon. We will implement an embeddable Link UI that handles authentication, token lifecycles, and configuration across multiple ATS providers in a single flow. By the end of this guide, you will know exactly how to drop in a secure authentication UI and start reading and writing unified candidate data.
The Multi-ATS Integration Bottleneck
Integration capabilities are a primary factor in software purchasing decisions. Effectively communicating software integration capabilities significantly expands buyer reach during the evaluation phase, according to Gartner's 2024 Global Software Buying Trends report. Yet, the broader integration data is brutal. A MuleSoft survey of 1,050 enterprise IT leaders found that 95% of respondents struggle to integrate data across systems, and only 29% of applications are typically connected within organizations, on average. Separate research indicates that 84% of all system integration projects fail or only partially succeed.
When a B2B SaaS company decides to build an ATS integration natively, they usually start with the market leader, like Greenhouse. The engineering team reads the documentation, sets up a developer account, and builds a bespoke OAuth 2.0 flow. They create database tables to store access tokens, refresh tokens, and expiration timestamps. They write a CRON job to refresh those tokens. They map the Greenhouse Candidate object to their internal schema.
Then the product manager asks for Lever.
As detailed in our engineering guide to the Lever API, Lever's API uses different pagination strategies. Their OAuth implementation has slightly different token expiration logic. Their candidate schema nests custom fields entirely differently than Greenhouse. The engineering team has to abstract their original Greenhouse code, build a generic interface, and write a new Lever adapter.
Then the product manager asks for Workable.
This cycle continues until a quarter of your engineering team is doing nothing but maintaining API adapters and debugging token refresh failures. Embedded iPaaS solutions attempt to solve this via visual workflow builders, but these are often overly complex and expensive for teams just needing a clean, embeddable connection UI and CRUD access. Other unified API providers force developers into rigid, standardized data models that frequently break on custom ATS fields.
We need a code-first, declarative approach that provides a unified schema without sacrificing access to custom fields. For a deeper teardown of why this is structurally hard, see our guide on integrating Greenhouse, Lever, and Workable.
What is an Embeddable Link UI?
A Link SDK is an embeddable JavaScript component that renders the authentication UI for third-party integrations directly inside your product. Instead of building a bespoke settings page for every ATS provider, you call one function—like link.open('greenhouse') or link.open('lever')—and embed a single component that handles the entire connection lifecycle.
Core responsibilities of an embeddable Link UI:
- Provider Selection: Displays a searchable directory of supported integrations.
- Credential Capture: Renders dynamic forms for API keys, subdomains, or custom headers based on the provider's auth requirements (e.g., Ashby uses admin-generated API keys, while Greenhouse uses OAuth).
- OAuth Lifecycle: Manages the OAuth 2.0 authorization code flow, PKCE validation, and secure token storage.
- Post-Connection Configuration: Handles provider-specific setup steps, like selecting default departments or mapping custom fields.
- Error Recovery: Guides users through re-authentication if a token is permanently revoked or expired.
Here is how the architecture flows when a user connects their ATS via a Link UI:
sequenceDiagram
participant User
participant Your App
participant Link UI
participant Truto
participant ATS Provider
User->>Your App: Clicks "Connect ATS"
Your App->>Truto: Request Link Token (Tenant ID)
Truto-->>Your App: Returns link_token
Your App->>Link UI: Initialize with Token & link.open()
Link UI->>User: Display Provider List (Greenhouse, Lever, etc.)
User->>Link UI: Selects Greenhouse
Link UI->>Truto: Initiate OAuth Flow
Truto->>ATS Provider: Redirect to Authorization URL
ATS Provider-->>User: Prompt for Consent
User->>ATS Provider: Approves Access
ATS Provider-->>Truto: Return Authorization Code + State
Truto->>ATS Provider: Exchange Code for Tokens
ATS Provider-->>Truto: Access + Refresh Tokens
Truto-->>Link UI: Connection Successful (integrated_account_id)
Link UI-->>Your App: onSuccess Callback
Note over Truto: Pre-schedules token refresh<br>before expiry via background taskThe trade-off worth being honest about: an embeddable component gives up some pixel-perfect control over the connection screen. If your design system demands that every modal match your exact tokens and motion specs, you will burn time customizing themes. Most teams find that the time saved on OAuth boilerplate is well worth giving up some UI control. For deeper UX patterns on this, our Link SDK design guide covers conversion principles in detail.
Step 1: Configuring the Unified ATS API
Before we embed the Link UI, we need to understand how data is normalized across these disparate systems. Truto's Unified ATS API provides a standardized data model to interact with various recruiting platforms. It abstracts away provider-specific nuances, allowing developers to programmatically manage job postings, candidate pipelines, and interview scheduling through a single schema.
In Truto, an integration represents a third-party service. The architecture relies on three layers:
- Integration Definition: A JSON configuration describing how to talk to a third-party API (base URL, auth scheme, pagination).
- Unified Model Mappings: A mapping layer that translates between standardized schemas and each integration's native format.
- Connected Accounts: When a customer connects their account, Truto creates an integrated account storing credentials and context data.
Every request includes an integrated_account_id query parameter that routes the call to the right provider. Your code does not branch on Greenhouse vs. Lever. Rather than writing integration-specific code, Truto normalizes data using declarative JSONata mapping expressions. A unified Candidate object will always look like this, regardless of whether the data originated from Ashby, Greenhouse, or Workable:
{
"id": "12345",
"first_name": "Jane",
"last_name": "Doe",
"primary_email": "jane.doe@example.com",
"phone": "+1-555-0123",
"applications": [
{
"id": "app_987",
"job_id": "job_456",
"status": "active",
"stage": "interview"
}
]
}The entity model covers what you actually need for hiring workflows: Jobs, Candidates, Applications, JobInterviewStages, Scorecards, Offers, Departments, Offices, and Attachments. Here is a minimal sync that pulls all candidates across any ATS:
const response = await fetch(
`https://api.truto.one/unified/ats/candidate?integrated_account_id=${accountId}&limit=100`,
{
headers: {
'Authorization': `Bearer ${process.env.TRUTO_API_KEY}`,
'x-truto-disable-cache': 'true'
}
}
);
const { result, next_cursor } = await response.json();
// result is normalized regardless of provider
for (const candidate of result) {
await db.candidates.upsert({
externalId: candidate.id,
firstName: candidate.first_name,
lastName: candidate.last_name,
email: candidate.primary_email
});
}The payoff is architectural. You write the loop once. Greenhouse pagination uses Link headers, Lever uses cursor offsets, and Workable uses page numbers. None of that leaks into your code—the unified API handles the complexity and returns a consistent next_cursor.
Be honest about the limits of normalization. No unified schema captures every custom field every ATS supports. Ashby has structured hiring plans, Greenhouse has custom application fields, and Lever has nested tags. Truto's architecture allows for environment-level overrides. You can inject custom JSONata logic to map those specific native fields into the unified response without waiting for a vendor to update their core schema.
Step 2: Dropping in the Link SDK
With the unified data model understood, we can embed the Link UI into our frontend application. Three steps get it working.
Generate a Link Token on Your Backend
Never expose your platform API key in the browser. First, your backend must generate a short-lived Link Token for the current user. This ensures the frontend cannot spoof connection requests for other tenants.
// Server-side: Node.js / Express
app.post('/api/integrations/link-token', async (req, res) => {
const { tenantId } = req.user;
const response = await fetch('https://api.truto.one/link-token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.TRUTO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tenant_id: tenantId,
integrations: ['greenhouse', 'lever', 'workable', 'ashby'],
unified_model: 'ats'
})
});
const data = await response.json();
res.json({ link_token: data.link_token });
});Mount the SDK in Your Frontend
Next, install the Link SDK in your frontend application and initialize it with the token. This example uses React, but the vanilla JavaScript implementation is nearly identical.
// Frontend: React
import { useState } from 'react';
import { TrutoLink } from '@truto/link-react';
export default function IntegrationsPage() {
const [linkToken, setLinkToken] = useState<string | null>(null);
const handleConnect = async () => {
const response = await fetch('/api/integrations/link-token', { method: 'POST' });
const data = await response.json();
setLinkToken(data.link_token);
};
const handleSuccess = ({ integratedAccountId, integration }) => {
console.log('Connected to ATS:', integration);
console.log('Account ID:', integratedAccountId);
// Save the integratedAccountId to your database against the tenant
};
return (
<div>
<h2>Applicant Tracking Systems</h2>
<button onClick={handleConnect}>Connect your ATS</button>
{linkToken && (
<TrutoLink
token={linkToken}
onSuccess={handleSuccess}
onClose={() => setLinkToken(null)}
/>
)}
</div>
);
}The Proactive Token Refresh Lifecycle
When a user successfully connects their ATS, the Link UI returns an integrated_account_id. This ID is all you need to make API calls—you never handle the actual OAuth tokens.
Behind the scenes, the integration platform handles the entire OAuth 2.0 authorization code flow, including PKCE validation. More importantly, it quietly saves the most engineering hours by handling proactive token refreshes.
OAuth tokens expire. Relying on reactive refreshes (waiting for an API call to fail with a 401 Unauthorized, then refreshing) leads to race conditions and dropped requests in highly concurrent systems. Truto solves this proactively. Before every API call, the platform checks if the token is expired using a 30-second buffer. If it is within the buffer, it refreshes the token before forwarding the request.
Additionally, the platform schedules work ahead of token expiry, refreshing credentials 60 to 180 seconds before they expire. If a refresh fails (e.g., the user revoked access in Greenhouse, or rotated credentials in Lever), the integrated account is marked as needs_reauth and an integrated_account:authentication_error webhook is fired. Your application can listen for this webhook and prompt the user to re-open the Link UI to restore the connection.
Step 3: Handling Rate Limits and Webhooks
This is where most teams get burned. Every ATS rate-limits differently. Greenhouse Harvest allows 50 requests per 10 seconds. Lever caps at 10 requests per second. Workable bursts at 60 per minute. If you wrote a naive sync loop, you would build five different backoff strategies.
Transparent Rate Limit Handling
One of the most dangerous anti-patterns in unified API platforms is the silent absorption of rate limits. Some platforms attempt to automatically retry requests when an upstream provider returns an HTTP 429 Too Many Requests error. This creates a massive backlog of queued requests, leading to unpredictable latency spikes and eventual system failure.
Truto does not silently retry, throttle, or apply backoff on rate limit errors. When an upstream ATS API returns an HTTP 429, Truto passes that error directly back to your application. Hiding 429s causes worse outages than surfacing them—your code loses observability into provider health, and retry storms can compound across customers.
To make this predictable, Truto normalizes upstream rate limit information into standardized IETF headers, regardless of what the upstream provider uses:
ratelimit-limit: The total number of requests allowed in the current window.ratelimit-remaining: The number of requests left in the current window.ratelimit-reset: The timestamp (or seconds remaining) when the rate limit window resets.
Your application is responsible for implementing proper exponential backoff with jitter based on these headers:
async function fetchCandidatesWithBackoff(integratedAccountId: string, attempt = 0): Promise<any> {
const response = await fetch(`https://api.truto.one/unified/ats/candidates?integrated_account_id=${integratedAccountId}`, {
headers: {
'Authorization': `Bearer ${process.env.TRUTO_API_KEY}`
}
});
if (response.status === 429) {
const resetTime = parseInt(response.headers.get('ratelimit-reset') || '5', 10);
// Calculate delay based on reset time, adding jitter to prevent thundering herds
const delayMs = (resetTime * 1000) + (Math.random() * 1000);
console.warn(`Rate limit hit. Backing off for ${delayMs}ms`);
await new Promise(resolve => setTimeout(resolve, delayMs));
if (attempt < 4) {
return fetchCandidatesWithBackoff(integratedAccountId, attempt + 1);
}
throw new Error('Rate limit exceeded after maximum retries');
}
return response.json();
}Normalized Webhook Delivery
Polling an ATS for new candidates every five minutes scales poorly past a few hundred customers and burns through rate limits quickly. Webhooks are essential for real-time synchronization. However, every ATS formats their webhooks differently. Lever might send a candidate.stage_change event, while Workable sends application_moved.
Truto handles incoming webhooks from third-party integrations, verifies their cryptographic signatures, and maps the proprietary event payload into a standardized unified webhook event. You only verify Truto's signature once.
When a candidate is moved to a new stage in any supported ATS, your application receives a single, normalized ats.application.updated or ats.candidate.created event containing the unified object. You only need to build one webhook handler to support real-time updates across your entire integration catalog.
// Server-side Webhook Receiver
app.post('/webhooks/truto', verifyTrutoSignature, async (req, res) => {
const { event_type, integrated_account_id, data } = req.body;
switch (event_type) {
case 'ats.candidate.created':
await ingestCandidate(integrated_account_id, data);
break;
case 'integrated_account:authentication_error':
await notifyUserToReconnect(integrated_account_id);
break;
}
res.status(200).end();
});Auth Patterns: How Greenhouse, Lever, and Workable Differ
The reason multi-ATS integration takes months is that each provider implements authentication differently. If you are building natively, here is the matrix you have to implement and maintain:
| Aspect | Greenhouse (Harvest) | Lever | Workable |
|---|---|---|---|
| Partner Auth | OAuth 2.0 Auth Code | OAuth 2.0 Auth Code | OAuth 2.0 Auth Code |
| Internal/Custom Auth | Basic Auth (API key as username, blank password) | Basic Auth (API key as username, blank password) | Bearer token (access token) |
| Token Expiry | 1h access / 24h refresh | 1h (3600s) | Varies by grant |
| Special Requirement | On-Behalf-Of header with user ID |
audience parameter in authorize URL |
Subdomain in base URL |
| Rate Limit | 50 req / 10s (Harvest v1/v2) | 10 req/s steady, 2 req/s for POSTs | ~60 req / min |
| Pagination Style | Link headers (RFC-5988) | Offset token cursor + hasNext |
Page-based |
Greenhouse's Harvest V3 API uses the standard OAuth 2.0 Authorization Code Grant flow, but it comes with Greenhouse-specific requirements. Access tokens expire after 1 hour, and the refresh token lasts 24 hours - if the refresh token expires unused, the user must repeat the entire authorization flow. That 24-hour refresh window is a trap. If your sync job runs weekly, every single Greenhouse connection will break on Monday morning.
For Lever, OAuth 2.0 is mandatory for partner integrations. Lever's implementation follows the standard Authorization Code Grant flow, but with Lever-specific requirements that trip up developers - including a required audience parameter and separate sandbox/production environments with different auth servers.
Workable's official partners access API endpoints through OAuth 2.0 using the Authorization Code flow, where the partner must be authorized beforehand by Workable to receive a client_id and client_secret. Workable also requires the customer's subdomain to construct the correct base URL for API requests.
Here is what a native Greenhouse OAuth token exchange looks like if you are building from scratch:
// Native Greenhouse OAuth - you'd have to build this for each provider
async function exchangeGreenhouseCode(code: string): Promise<TokenResponse> {
const response = await fetch('https://api.greenhouse.io/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: process.env.GREENHOUSE_CLIENT_ID!,
client_secret: process.env.GREENHOUSE_CLIENT_SECRET!,
redirect_uri: process.env.GREENHOUSE_REDIRECT_URI!
})
});
return response.json();
}
// Then you'd write a nearly identical function for Lever...
async function exchangeLeverCode(code: string): Promise<TokenResponse> {
const response = await fetch('https://auth.lever.co/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: process.env.LEVER_CLIENT_ID!,
client_secret: process.env.LEVER_CLIENT_SECRET!,
redirect_uri: process.env.LEVER_REDIRECT_URI!
})
});
return response.json();
}
// ...and another for Workable, with a subdomain requirementThat's three OAuth implementations before you've written a single line of business logic. Each one needs its own token storage, refresh scheduling, error handling, and re-auth flow.
With the unified approach, your code never touches OAuth. You call the Link UI, get back an integrated_account_id, and make API calls. The platform handles credential resolution through a hierarchy - base OAuth config at the integration level, overrides at the environment level, and per-account overrides when needed. It figures out whether the account uses OAuth 2.0 authorization code, client credentials, or API key auth and applies the right strategy.
Test case: Token expiry across providers. After connecting test accounts for all three providers, wait 90 minutes and make an API call to each. Verify that the unified API returns fresh data without a 401. This confirms that proactive token refresh is working for Greenhouse (1h expiry), Lever (1h expiry), and Workable.
Pagination Translation: From Link Headers to a Unified Cursor
Every ATS paginates differently. This is one of the most tedious aspects of multi-provider integration, and getting it wrong means missing candidate records or duplicating them.
What Each Provider Returns Natively
Greenhouse uses RFC-5988 Link headers. Paginated results include a Link response header that looks like this:
HTTP/1.1 200 OK
Link: <https://harvest.greenhouse.io/v1/candidates?page=2&per_page=100>; rel="next",
<https://harvest.greenhouse.io/v1/candidates?page=47&per_page=100>; rel="last"
X-RateLimit-Limit: 50
X-RateLimit-Remaining: 49
Harvest v3 uses cursor-based pagination for list endpoints. Passing a cursor with any other query params returns a 422 error - the cursor must be the only parameter on subsequent requests.
Lever returns cursor data in the JSON response body. Lever uses cursor-based pagination with list endpoints returning a next cursor token and a hasNext boolean, with configurable page size between 1 and 100 items.
{
"data": [
{ "id": "abc-123", "name": "Jane Doe", "stage": "interview" }
],
"hasNext": true,
"next": "1686268200000_abc-124"
}Workable uses classic page-number pagination with a paging object in the response body.
The Unified Cursor Interface
Instead of writing three different pagination parsers, every unified API response returns the same structure:
{
"result": [
{ "id": "12345", "first_name": "Jane", "last_name": "Doe" }
],
"next_cursor": "eyJwYWdlIjoyLCJwZXJfcGFnZSI6MTAwfQ=="
}When next_cursor is null, you have reached the last page. Here is a complete paginated sync that works identically across Greenhouse, Lever, and Workable:
async function syncAllCandidates(integratedAccountId: string): Promise<void> {
let cursor: string | null = null;
let totalSynced = 0;
do {
const url = new URL('https://api.truto.one/unified/ats/candidates');
url.searchParams.set('integrated_account_id', integratedAccountId);
url.searchParams.set('limit', '100');
if (cursor) {
url.searchParams.set('cursor', cursor);
}
const response = await fetch(url.toString(), {
headers: { 'Authorization': `Bearer ${process.env.TRUTO_API_KEY}` }
});
if (response.status === 429) {
const resetSeconds = parseInt(response.headers.get('ratelimit-reset') || '5', 10);
await new Promise(resolve => setTimeout(resolve, resetSeconds * 1000 + Math.random() * 1000));
continue; // retry the same page
}
if (!response.ok) {
throw new Error(`Sync failed: ${response.status} ${response.statusText}`);
}
const { result, next_cursor } = await response.json();
for (const candidate of result) {
await db.candidates.upsert({
externalId: candidate.id,
integratedAccountId,
firstName: candidate.first_name,
lastName: candidate.last_name,
email: candidate.primary_email,
lastSyncedAt: new Date()
});
}
totalSynced += result.length;
cursor = next_cursor;
} while (cursor);
console.log(`Synced ${totalSynced} candidates for account ${integratedAccountId}`);
}Behind the scenes, the platform translates Greenhouse's Link header URLs into cursors, extracts Lever's next offset token, and converts Workable's page numbers. Your code never parses a Link header or checks hasNext.
Verifying Webhook Signatures
Every ATS signs their webhooks differently. Lever signs webhook requests and includes the signature within the body of the POST request, with a signature parameter included if a signing token has been generated. Greenhouse uses a different HMAC format. Workable has its own scheme.
With a unified webhook pipeline, you verify one signature - Truto's. The platform receives the native webhook from the ATS, verifies the provider's cryptographic signature using timing-safe comparison, maps the event to the unified schema, and re-signs the payload before delivering it to your endpoint.
Here is a complete verification implementation using X-Truto-Signature:
import crypto from 'crypto';
function verifyTrutoSignature(
req: Request,
res: Response,
next: NextFunction
): void {
const signature = req.headers['x-truto-signature'] as string;
const webhookSecret = process.env.TRUTO_WEBHOOK_SECRET!;
if (!signature) {
res.status(401).json({ error: 'Missing signature header' });
return;
}
const rawBody = (req as any).rawBody; // requires raw body middleware
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
// Timing-safe comparison to prevent side-channel attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
next();
}You must use timing-safe comparison. A naive string equality check (===) leaks information about which bytes of the signature are correct, making it vulnerable to timing attacks. Always use crypto.timingSafeEqual or your language's equivalent.
For your retry strategy, respond with a 200 OK as quickly as possible. If your webhook handler needs to do slow work (database writes, downstream API calls), acknowledge the webhook first and process asynchronously:
app.post('/webhooks/truto', verifyTrutoSignature, async (req, res) => {
// Acknowledge immediately
res.status(200).end();
// Process asynchronously
try {
const { event_type, integrated_account_id, data } = req.body;
await processWebhookEvent(event_type, integrated_account_id, data);
} catch (err) {
console.error('Webhook processing failed:', err);
// Queue for retry in your own system
await retryQueue.enqueue(req.body);
}
});Building a Reconciliation Job
Webhooks handle real-time updates, but they are not a complete data strategy. Webhooks can be missed due to network issues, your endpoint being temporarily down, or provider-side delivery failures. A periodic reconciliation job fills the gaps.
This pattern runs on a schedule (every 6-12 hours), fetches records updated since the last sync, and upserts them into your database. It acts as a safety net for any events your webhook handler missed.
interface SyncState {
integratedAccountId: string;
lastSyncedAt: string; // ISO-8601 timestamp
}
async function reconcileCandidates(state: SyncState): Promise<SyncState> {
let cursor: string | null = null;
let recordsProcessed = 0;
const syncStartedAt = new Date().toISOString();
do {
const url = new URL('https://api.truto.one/unified/ats/candidates');
url.searchParams.set('integrated_account_id', state.integratedAccountId);
url.searchParams.set('limit', '100');
url.searchParams.set('updated_after', state.lastSyncedAt);
if (cursor) {
url.searchParams.set('cursor', cursor);
}
const response = await fetch(url.toString(), {
headers: { 'Authorization': `Bearer ${process.env.TRUTO_API_KEY}` }
});
if (response.status === 429) {
const resetSeconds = parseInt(
response.headers.get('ratelimit-reset') || '10', 10
);
await new Promise(r => setTimeout(r, (resetSeconds + 1) * 1000));
continue;
}
if (!response.ok) {
throw new Error(`Reconciliation failed: ${response.status}`);
}
const { result, next_cursor } = await response.json();
for (const candidate of result) {
await db.candidates.upsert({
externalId: candidate.id,
integratedAccountId: state.integratedAccountId,
firstName: candidate.first_name,
lastName: candidate.last_name,
email: candidate.primary_email,
rawPayload: candidate, // store for debugging
lastSyncedAt: new Date()
});
recordsProcessed++;
}
cursor = next_cursor;
} while (cursor);
console.log(`Reconciliation complete: ${recordsProcessed} records for ${state.integratedAccountId}`);
return {
...state,
lastSyncedAt: syncStartedAt
};
}
// Run for all connected accounts
async function runFullReconciliation(): Promise<void> {
const accounts = await db.integratedAccounts.findAll({ status: 'active' });
for (const account of accounts) {
try {
const state = await db.syncState.findOne(account.id) || {
integratedAccountId: account.id,
lastSyncedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
};
const newState = await reconcileCandidates(state);
await db.syncState.upsert(newState);
} catch (err) {
console.error(`Reconciliation failed for ${account.id}:`, err);
// Continue with next account - don't let one failure block others
}
}
}Test cases for your reconciliation job:
- Happy path: Connect a test account, create a candidate in the ATS UI, run the job, verify the record appears in your database.
- Idempotency: Run the job twice in a row. Confirm no duplicate records are created (the upsert should update, not insert).
- Rate limit handling: Set
limit=1to force many pages and trigger rate limits faster. Verify the job completes without crashing. - Stale token: Revoke the OAuth token in the ATS provider's UI, then run the job. Verify your system receives the
integrated_account:authentication_errorwebhook and marks the account for re-auth instead of silently failing. - Empty response: Run against an ATS with no candidates. Verify the job completes with
0 recordsand updates the sync timestamp.
Debugging and Monitoring Multi-ATS Integrations
Once you are in production with multiple ATS providers, these are the issues that will wake you up at 3am.
Common Failure Scenarios
"401 Unauthorized from Greenhouse after the weekend." If the refresh token is not used within 24 hours, the user must repeat the entire authorization flow. This catches teams who rely on weekly sync jobs. The fix: proactive token refresh on a schedule, well before the 24-hour refresh window. Truto handles this by refreshing credentials 60-180 seconds before expiry and scheduling follow-up refreshes immediately after each successful token rotation.
"Lever returns 429 on bulk candidate import." Lever enforces a steady-state rate limit of 10 requests per second per API key, with burst capacity up to 20. Application POST requests have a stricter limit of just 2 requests per second. If you are creating candidates in bulk through the unified API, you need to pace your writes to respect that 2 req/s POST ceiling. Check the ratelimit-remaining header after each write and back off proactively when it drops below 2.
"Greenhouse API returns 403 on list endpoints." All list endpoints require authorization by a Greenhouse user with Site Admin privileges. If a non-Site Admin authorized the connection, attempts to access these endpoints will result in a 403. This is a permissions issue, not an auth issue. Your Link UI should document the required permission level during the connection flow.
"Webhook events arriving but data looks stale." This typically means your webhook handler is processing events but the underlying API call to fetch the full record is hitting a cached or stale response. Always pass x-truto-disable-cache: true on reads triggered by webhook events to ensure fresh data.
"Connection works for one customer but fails for another on the same provider." Each ATS customer's account can have different permission configurations, custom fields, and API access levels. Use the integrated_account_id to isolate debugging. Make a direct API call for the failing account and inspect the error response - it usually reveals a missing permission or a deactivated user.
Recommended Monitoring
Set up alerts on these signals:
integrated_account:authentication_errorwebhook count - a spike means tokens are failing to refresh across multiple customers. Investigate immediately.- HTTP 429 response rate per provider - track this over time to spot customers approaching rate limit ceilings. Consider staggering sync jobs.
- Reconciliation job duration and record count - a sudden drop in records processed could indicate a silent auth failure or API change.
- Webhook delivery latency - measure the time between your
200 OKresponse and the completion of async processing. If this grows, your processing queue is backing up. needs_reauthaccount count - this should be a small percentage of total connected accounts. If it creeps above 5%, something systemic is wrong with token refresh.
// Example: simple monitoring for your reconciliation job
async function monitoredReconciliation(accountId: string): Promise<void> {
const startTime = Date.now();
const metrics = { recordsProcessed: 0, rateLimitHits: 0, errors: 0 };
try {
const state = await db.syncState.findOne(accountId);
const result = await reconcileCandidates(state!);
metrics.recordsProcessed = /* count from result */0;
} catch (err) {
metrics.errors++;
throw err;
} finally {
const duration = Date.now() - startTime;
await telemetry.record('reconciliation.completed', {
accountId,
durationMs: duration,
...metrics
});
if (duration > 300_000) { // 5 minutes
await alerts.warn(`Reconciliation for ${accountId} took ${duration}ms`);
}
}
}Stop Building Custom Settings Pages
Your integrations directory is not a feature checkbox. It is a revenue page. If a prospect cannot find their specific ATS on your marketing site, you are functionally invisible to them during the evaluation phase.
The arithmetic that should drive your decision: every custom ATS settings page you build is roughly four to six weeks of senior engineering, plus 15-20% of that cost annually in maintenance, plus an unbounded amount of on-call time for token refresh failures and breaking API changes. Multiply by the number of ATSs your sales team needs to win deals, and you have an engineering org that ships nothing else.
By leveraging an embeddable Link UI and a declarative unified API, you flip this arithmetic. One frontend component, one backend endpoint, one webhook receiver. You bypass the need to build custom settings pages, bespoke OAuth flow handlers, and complex token refresh CRON jobs. Adding a new ATS to your supported list becomes a configuration change, allowing you to launch integrations with Greenhouse, Lever, Workable, Ashby, and dozens of other platforms in a matter of days, not quarters.
Stop punishing your senior engineers with API documentation for legacy recruiting platforms. Standardize your connection flows, normalize your data models, and unblock your sales pipeline.
FAQ
- How do I integrate with Greenhouse, Lever, and Workable APIs at the same time?
- Use a unified ATS API that normalizes all three providers behind a single endpoint. You embed a Link UI component that handles OAuth for each provider, get back an integrated_account_id, and make the same API calls regardless of which ATS your customer uses. The unified API translates pagination formats, auth schemes, and data models so your code never branches on provider.
- What are the authentication differences between Greenhouse, Lever, and Workable APIs?
- All three support OAuth 2.0 for partner integrations, but with different requirements. Greenhouse Harvest v3 uses OAuth with 1-hour access tokens and 24-hour refresh tokens, plus a mandatory On-Behalf-Of header. Lever requires an audience parameter in the OAuth flow and enforces OAuth for all partner integrations. Workable needs the customer's subdomain to construct API URLs. A unified API abstracts all of this behind a single connection flow.
- How do I handle pagination differences across ATS APIs like Greenhouse and Lever?
- Greenhouse uses RFC-5988 Link headers (page-based in v1/v2, cursor-based in v3). Lever returns an offset token and hasNext boolean in the JSON body. Workable uses page numbers. A unified API translates all of these into a single next_cursor field - when it's null, you've reached the last page. Your code uses one pagination loop for all providers.
- What rate limits do Greenhouse, Lever, and Workable enforce?
- Greenhouse Harvest allows 50 requests per 10-second window. Lever permits 10 requests per second steady-state (bursts up to 20), with POST operations limited to 2 per second. Workable allows approximately 60 requests per minute. All three return HTTP 429 when limits are exceeded. A unified API normalizes rate limit information into standardized IETF headers so you can implement one backoff strategy.
- How do I keep ATS data in sync across multiple providers?
- Use a two-layer approach: webhooks for real-time updates and a periodic reconciliation job as a safety net. Subscribe to unified webhook events for candidate and application changes. Then run a reconciliation job every 6-12 hours that fetches records updated since the last sync using the updated_after parameter, upserting them into your database to catch any missed webhook events.