Skip to content

Developer Quickstart: Building a Multi-ATS Link UI for Greenhouse, Lever & More

A technical blueprint for embedding a single Link UI that handles OAuth, token refresh, and unified API connections across Greenhouse, Lever, Workable, and Ashby.

Roopendra Talekar Roopendra Talekar · · 11 min read
Developer Quickstart: Building a Multi-ATS Link UI for Greenhouse, Lever & More

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.

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 task

The 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:

  1. Integration Definition: A JSON configuration describing how to talk to a third-party API (base URL, auth scheme, pagination).
  2. Unified Model Mappings: A mapping layer that translates between standardized schemas and each integration's native format.
  3. 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.

Warning

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.

With the unified data model understood, we can embed the Link UI into our frontend application. Three steps get it working.

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();
});

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

What is an embeddable Link UI for SaaS integrations?
A Link UI is a drop-in frontend component that handles provider selection, dynamic credential forms, and the entire OAuth 2.0 lifecycle for third-party integrations. Instead of building custom settings pages for Greenhouse, Lever, and Workable, you call one function and the SDK manages the connection flow.
How does a unified ATS API handle differences between Greenhouse, Lever, and Workable?
A unified API normalizes the request and response shapes so a single GET /candidates call returns the same schema regardless of the underlying provider. Pagination (like Greenhouse Link headers vs Lever cursors), authentication, and field naming differences are abstracted away, leaving your application code provider-agnostic.
How does Truto handle API rate limits across multiple ATS providers?
Truto passes HTTP 429 Too Many Requests errors directly back to your caller rather than silently retrying, which prevents retry storms. The platform normalizes upstream rate limit information into standardized IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) so you can implement consistent exponential backoff.
What happens when an OAuth token for an ATS expires or is revoked?
Truto proactively refreshes OAuth tokens by checking for expiration with a 30-second buffer before API calls, and by scheduling background refresh tasks 60-180 seconds before expiry. If a refresh fails (e.g., the user revoked the app), an integrated_account:authentication_error webhook fires so you can prompt the user to reconnect.
Can I access custom fields in a Unified ATS API?
Yes. While unified data models cover the common case, Truto uses a declarative JSONata mapping layer that allows you to configure environment-level overrides. This ensures you can extract and map highly specific custom fields or vendor-specific objects without waiting for platform updates.

More from our Blog