---
title: "Developer Quickstart: Building a Multi-ATS Link UI for Greenhouse, Lever & More"
slug: developer-quickstart-building-a-multi-ats-link-ui-for-b2b-saas
date: 2026-05-18
author: Roopendra Talekar
categories: [Guides, By Example]
excerpt: "A technical blueprint for embedding a single Link UI that handles OAuth, token refresh, and unified API connections across Greenhouse, Lever, Workable, and Ashby."
tldr: "Stop building custom OAuth flows for every ATS. Use an embeddable Link UI and a Unified ATS API to normalize data, handle rate limits, and accelerate enterprise integration delivery."
canonical: https://truto.one/blog/developer-quickstart-building-a-multi-ats-link-ui-for-b2b-saas/
---

# 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. <cite index="6-2">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.</cite> 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. <cite index="12-11,12-12">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.</cite> <cite index="17-17">Separate research indicates that 84% of all system integration projects fail or only partially succeed.</cite>

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](https://truto.one/how-to-integrate-with-the-lever-api-2026-engineering-guide/), 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](https://truto.one/how-to-integrate-multiple-ats-platforms-greenhouse-lever-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:

```mermaid
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](https://truto.one/how-to-build-and-document-a-high-converting-link-sdk-for-saas-integrations/) 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](https://truto.one/what-are-ats-integrations-2026-architecture-strategy-guide/) 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:

```json
{
  "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:

```typescript
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.

## 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.

```typescript
// 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.

```tsx
// 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:

```typescript
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](https://truto.one/developer-quickstart-link-ui-unified-webhooks-for-b2b-saas/). 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.

```typescript
// 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.

> Ready to stop building bespoke ATS integrations? Talk to our engineering team about embedding a unified Link UI into your SaaS product today to handle custom fields, webhooks, and multi-ATS connections.
>
> [Talk to us](https://cal.com/truto/partner-with-truto)
