How to Build and Document a High-Converting Link SDK for SaaS Integrations
Build a white-labeled integration marketplace with an embeddable Link SDK: OAuth flows, credential vault, token workers, webhooks, and production SLOs.
Your product manager just promised an enterprise prospect HubSpot, Salesforce, and BambooHR integrations by the end of the quarter. Your senior backend engineer estimates six weeks for the OAuth flow alone, another four weeks to build webhook signature verification across three different providers, and an unknown amount of time for the inevitable token-refresh fires that will trigger PagerDuty alerts at 3 AM. The deal closes in five weeks.
You are a startup CTO or engineering leader staring at a spreadsheet of lost deals, and the pattern is painfully obvious. Prospects run a successful evaluation, love your core product, and then ask the inevitable question: "Do you integrate with our HRIS and our CRM?" You reply that it is on the roadmap. By the time your engineering team ships it, the deal is completely cold.
The fastest way out of that math is to stop building a custom settings page for every integration and start shipping an embeddable Link SDK—a single drop-in component that handles OAuth, post-connection configuration, and the inevitable mess of native file pickers across every provider you support.
This guide provides a technical blueprint for building a white-labeled integration marketplace and embeddable Link SDK for your SaaS application. We will cover marketplace architecture, white-labeled UI components with runnable code, OAuth flows, credential vault patterns, token manager workers, webhook verification and event mapping, and production readiness criteria - logging, alert rules, and SLAs.
What is a Link SDK and Why Does It Matter for SaaS Integrations
A Link SDK is an embeddable JavaScript (or native mobile) component that renders the authentication UI for third-party integrations directly inside your product. Instead of building a bespoke Salesforce settings page, a HubSpot settings page, and a NetSuite settings page, you call a single link.open('salesforce') function. The SDK then handles OAuth redirects, token storage, error recovery, and post-connection configuration.
Why this matters in 2026:
- 77% of buyers prioritize integration capabilities, and solutions that fail to integrate seamlessly with existing workflows are often deprioritized "regardless of features or price" (Demandbase, 2025). During assessment, buyers are primarily concerned with a software provider's ability to provide integration support (44%), making it the number one sales-related factor in driving a software decision, according to Gartner's 2024 Global Software Buying Trends report.
- Buyers still mostly or fully define their purchase requirements 83% of the time before speaking with sales (6Sense, 2025). If your application lacks a polished, embedded way to connect third-party tools, prospects will assume your platform is an isolated silo.
- Building OAuth for AI agents costs $200K+ over 3 years in engineering, maintenance, and delayed deals. The same math applies to customer-facing OAuth.
For the architectural difference between this and an internal automation, see What is a Customer-Facing Integration?.
White-Label Integration Marketplace: Architecture Overview
A white-labeled integration marketplace is the customer-facing surface where your users discover, connect, and manage third-party integrations - all under your brand. The architecture breaks into three layers that map directly to the engineering work your team needs to own or outsource.
flowchart TB
subgraph Frontend["Your SaaS Application (White-Labeled)"]
A["Integration Marketplace<br>(Catalog + Search + Filters)"]
B["Connect Modal<br>(Link SDK)"]
C["Connection Dashboard<br>(Status + Reauth)"]
end
subgraph Platform["Integration Platform"]
D["Integration Registry<br>(Provider Configs)"]
E["OAuth Engine<br>(Auth + Token Exchange)"]
F["Encrypted Token Store"]
G["Token Refresh Worker<br>(Proactive Refresh)"]
H["Unified API"]
I["Webhook Router"]
end
subgraph Providers["Third-Party APIs"]
J["CRM<br>(Salesforce, HubSpot)"]
K["HRIS<br>(BambooHR, Workday)"]
L["File Storage<br>(Drive, SharePoint)"]
end
A -->|"List integrations"| D
B -->|"OAuth flow"| E
E -->|"Store tokens"| F
G -->|"Refresh before expiry"| F
C -->|"Check status"| F
H -->|"API calls"| J & K & L
I -->|"Webhooks"| J & K & L
E -->|"Token exchange"| J & K & LKey architectural decisions for a white-label integration marketplace
Configuration-driven integrations. Adding a new provider to your marketplace should be a data operation, not a code deployment. Each integration is defined by a JSON configuration describing the base URL, authentication method, pagination strategy, and resource endpoints. The same runtime engine processes every integration - no provider-specific code paths. This is the single most important architectural choice. A code-per-provider design means your marketplace scales linearly with engineering headcount. A config-driven design means it scales with a database insert.
Multi-tenant isolation. Every customer environment gets its own integration configuration layer. One customer's Salesforce OAuth app credentials, scopes, and field mappings are isolated from another's. The platform manages a three-level hierarchy - platform defaults, environment-level overrides, and per-account customizations - all deep-merged at runtime.
Override hierarchy for enterprise customers. Enterprise accounts often need custom field mappings, different OAuth scopes, or alternative API endpoints.
| Level | Scope | Example |
|---|---|---|
| Platform default | All customers | Standard Salesforce contact fields |
| Environment override | One customer's environment | Add custom lead_score field mapping |
| Account override | One connected account | Route to a sandbox API endpoint |
Each level deep-merges on top of the previous, giving the most specific configuration priority without duplicating the entire config.
White-label theming. Your marketplace UI should accept CSS custom properties for typography, colors, border radius, and spacing. The connect modal, integration cards, and status badges should all inherit your design system. Provider-owned screens (Google's consent page, Microsoft's login) cannot be restyled, but everything before and after those screens should feel native to your product.
The Business Case for an Embeddable Integration UI
Embeddable integration UI best practices start with acknowledging that your integrations page is a revenue driver, not just a technical settings panel.
Building a custom integration authentication UI from scratch is a massive capital expense. Organizations typically spend $30,000 to $500,000+ just for the initial build of their integration infrastructure, with annual maintenance running 15 to 25% of that original cost. That is merely the hard build number. The hidden cost is opportunity: every hour your senior engineers spend debugging a broken Salesforce custom field mapping or deciphering a cryptic OAuth error is an hour they are not building your core product.
The most expensive line item in a custom integration program is not the initial OAuth build. It is the long tail of provider-specific weirdness that compounds for years afterward.
| Cost Bucket | In-House Build | Embeddable Link SDK |
|---|---|---|
| Initial OAuth build (per provider) | 2 - 6 weeks | < 1 day |
| Token refresh edge cases | Permanent on-call surface | Handled by vendor |
| Provider auth changes (Microsoft renaming AAD again) | Emergency sprint | Patch release |
| File picker UIs (Drive, SharePoint, Box) | 4 - 8 weeks each | Single function call |
| Annual maintenance per integration | $50,000 - $150,000 | Included |
The Authsignal team puts it bluntly: "Authentication is a specialization. The teams who do it well have spent years on it, across hundreds of integrations, building up an understanding of failure modes that aren't obvious until they are. They've tracked regulatory changes across different jurisdictions. They've maintained libraries through breaking changes and security disclosures. That expertise accumulates slowly and it's genuinely hard to replicate quickly."
FusionAuth is more direct: "Fewer than 5% of engineering teams should build authentication from scratch. The complexity, security requirements, and ongoing maintenance typically outweigh the benefits unless you have very specific requirements and dedicated security expertise."
If you would not build your own SAML library, you should not build your own multi-provider OAuth UI either. An embeddable Link SDK solves this by moving the entire authentication lifecycle, token management, and UI rendering to a managed component.
Core UX Principles for High-Converting Authentication Flows
When a user clicks "Connect to Salesforce" inside your application, the subsequent flow determines whether they complete the integration or abandon the setup. A Link SDK lives in the moment a customer decides whether your product is real or vapor. Treat it like a checkout flow, not a settings panel.
The non-negotiables for Link SDK authentication UX:
- One click to start: No "go to integrations, click new, select provider, paste API key." One button, one modal.
- Eliminate the redirect loop of death: Never force the user to leave your application's context. Use popup windows or centered iframes for the OAuth grant screen, communicating back to the host window via the
MessageChannelAPI orpostMessage. - Maintain brand consistency: The SDK should accept CSS variables or configuration objects that match the host application's typography, button border radius, and primary colors. White-label API authentication builds trust. If it looks like a third-party widget, enterprise users will flag it in security review.
- Handle popup blockers gracefully: Browsers block
window.opencalls that do not originate from a direct user interaction (like a click event). The SDK must initiate the popup synchronously within the click handler, then navigate the popup to the authorization URL once the backend generates the state parameters. - Inline error states: When Salesforce returns
invalid_grantbecause the user is not an org admin, show a human-readable explanation, not a JSON dump. - Resumable flows: OAuth redirects break tabs, popup blockers, and mobile in-app browsers. The SDK must recover when a user comes back to a half-finished flow.
- Pre-connection clarity: Tell users exactly which scopes you are requesting and why. "We need
crm.objects.contacts.readto sync your contact list every 15 minutes" beats a Microsoft consent screen that lists 14 permissions. - Provide clear exit states: If a user cancels the flow, the SDK must return a predictable error state to the host application rather than leaving the UI hanging in a loading state.
Always pass a secure, unpredictable state parameter during the OAuth flow and verify it upon the callback. This prevents Cross-Site Request Forgery (CSRF) attacks against the integration setup process.
sequenceDiagram
participant User
participant HostApp as Host Application
participant SDK as Link SDK
participant Backend as Integration Platform
participant Provider as Third-Party API
User->>HostApp: Clicks "Connect"
HostApp->>SDK: link.open()
SDK->>Backend: Request Auth URL
Backend-->>SDK: Return URL with state
SDK->>SDK: Validate scopes, show consent screen
SDK->>Provider: Open popup to Auth URL
User->>Provider: Grants permission
Provider->>Backend: Redirect with authorization code
Backend->>Provider: Exchange code for tokens
Provider-->>Backend: Access & Refresh Tokens
Backend-->>SDK: PostMessage (Success)
SDK-->>HostApp: onSuccess(integratedAccount)
HostApp->>User: "Connected. Importing data..."The most important conversion lever is what happens immediately after the success callback. A connected account that immediately shows imported data converts dramatically better than one that says "sync starting, check back in an hour."
White-Labeled Connect UI: Component Code
Below is a production-ready set of React components and CSS that render a white-labeled integration marketplace. Every visual element inherits from CSS custom properties, so your design team can restyle the entire marketplace by overriding a handful of variables.
CSS: White-Label Custom Properties
/* Override these custom properties to match your brand */
.tm-marketplace {
--tm-primary: #4f46e5;
--tm-primary-hover: #4338ca;
--tm-bg: #ffffff;
--tm-text: #111827;
--tm-text-muted: #6b7280;
--tm-border: #e5e7eb;
--tm-success: #059669;
--tm-warning: #d97706;
--tm-radius: 8px;
--tm-font: 'Inter', system-ui, sans-serif;
font-family: var(--tm-font);
color: var(--tm-text);
}
.tm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.tm-card {
background: var(--tm-bg);
border: 1px solid var(--tm-border);
border-radius: var(--tm-radius);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: box-shadow 0.15s ease;
}
.tm-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.tm-card-logo { width: 40px; height: 40px; border-radius: 6px; }
.tm-card-title { font-size: 1rem; font-weight: 600; margin: 0; }
.tm-card-desc { font-size: 0.875rem; color: var(--tm-text-muted); margin: 0; }
.tm-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: auto; }
.tm-card-category { font-size: 0.75rem; color: var(--tm-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.tm-badge { font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 999px; font-weight: 500; }
.tm-badge-success { background: #ecfdf5; color: var(--tm-success); }
.tm-badge-warning { background: #fffbeb; color: var(--tm-warning); }
.tm-btn { padding: 0.5rem 1rem; border-radius: var(--tm-radius); font-size: 0.875rem; font-weight: 500; cursor: pointer; border: none; }
.tm-btn-primary { background: var(--tm-primary); color: white; }
.tm-btn-primary:hover { background: var(--tm-primary-hover); }
.tm-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.tm-btn-secondary { background: #f9fafb; color: var(--tm-text); border: 1px solid var(--tm-border); }
.tm-search { flex: 1; min-width: 200px; padding: 0.625rem 1rem; border: 1px solid var(--tm-border); border-radius: var(--tm-radius); font-size: 0.875rem; outline: none; }
.tm-search:focus { border-color: var(--tm-primary); box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
.tm-filters { display: flex; gap: 0.5rem; }
.tm-filter { padding: 0.5rem 1rem; border: 1px solid var(--tm-border); border-radius: var(--tm-radius); background: var(--tm-bg); cursor: pointer; font-size: 0.75rem; text-transform: uppercase; }
.tm-filter.active { background: var(--tm-primary); color: white; border-color: var(--tm-primary); }React: Integration Marketplace and Card Components
import { useState } from 'react';
interface Integration {
id: string;
name: string;
logo_url: string;
description: string;
category: string;
status?: 'active' | 'needs_reauth' | 'connecting' | null;
integrated_account_id?: string;
}
function IntegrationCard({
integration,
onConnect,
isConnecting,
}: {
integration: Integration;
onConnect: () => void;
isConnecting: boolean;
}) {
const isConnected = integration.status === 'active';
const needsReauth = integration.status === 'needs_reauth';
return (
<div className="tm-card">
<div className="tm-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<img src={integration.logo_url} alt="" className="tm-card-logo" />
{isConnected && <span className="tm-badge tm-badge-success">Connected</span>}
{needsReauth && <span className="tm-badge tm-badge-warning">Reconnect</span>}
</div>
<h3 className="tm-card-title">{integration.name}</h3>
<p className="tm-card-desc">{integration.description}</p>
<div className="tm-card-footer">
<span className="tm-card-category">{integration.category}</span>
{isConnected ? (
<button className="tm-btn tm-btn-secondary">Manage</button>
) : (
<button className="tm-btn tm-btn-primary" onClick={onConnect} disabled={isConnecting}>
{isConnecting ? 'Connecting...' : needsReauth ? 'Reconnect' : 'Connect'}
</button>
)}
</div>
</div>
);
}
export function IntegrationMarketplace({
integrations,
onConnect,
}: {
integrations: Integration[];
onConnect: (integrationId: string) => void;
}) {
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('all');
const [connectingId, setConnectingId] = useState<string | null>(null);
const categories = ['all', ...new Set(integrations.map((i) => i.category))];
const filtered = integrations.filter((i) => {
const matchesSearch = i.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = activeCategory === 'all' || i.category === activeCategory;
return matchesSearch && matchesCategory;
});
const handleConnect = async (id: string) => {
setConnectingId(id);
try {
await onConnect(id);
} finally {
setConnectingId(null);
}
};
return (
<div className="tm-marketplace">
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
<input
type="search"
placeholder="Search integrations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="tm-search"
/>
<div className="tm-filters">
{categories.map((cat) => (
<button
key={cat}
className={`tm-filter ${activeCategory === cat ? 'active' : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat === 'all' ? 'All' : cat}
</button>
))}
</div>
</div>
<div className="tm-grid">
{filtered.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
onConnect={() => handleConnect(integration.id)}
isConnecting={connectingId === integration.id}
/>
))}
</div>
</div>
);
}To white-label this for a specific customer, override the CSS custom properties on the .tm-marketplace container. Every card, badge, button, and input inherits from those properties. No prop drilling, no theme provider - just CSS.
Developer Experience (DX): Designing the SDK API Surface
SDKs act as a critical layer between host applications and the underlying system, where performance, security, and reliability matter most. If the implementing engineer at your customer's company has to read more than one page of docs to get a connect button rendering, you have already lost.
A utility class bundled with the SDK can take care of the Token Management life cycle and securely store all tokens. All the developer had to do was say, "Give me an AT (Access Token)," and have confidence in returning a valid AT without being concerned with what was happening under the hood. Apply the same philosophy to your Link SDK. Implementing engineers do not want to manage session state, poll for completion, or parse complex initialization objects.
Design rules for a high-converting API surface:
- One initialization function. Not a builder, not a factory, not a config DSL. They want a single function call.
- Promises over callbacks. Modern engineers expect
await link.connect(...). - TypeScript first. Ship types in the package. SDKs that are written in the targeted language can be more idiomatic when it comes to type safety. An SDK also makes it easier to predict how an API will respond to a request since the response object is typed.
- Predictable errors. Throw typed errors with stable error codes (
USER_CANCELLED,POPUP_BLOCKED,INVALID_SCOPE,TOKEN_EXPIRED), not stringly-typed exceptions. - Zero peer dependency drama. If your SDK forces React 18 or a specific bundler, you have made it un-installable for half your prospects.
A minimal surface that works looks like this:
import { TrutoLink } from '@truto/truto-link-sdk';
// Initialize the SDK with a single function
const link = new TrutoLink({
token: 'YOUR_SHORT_LIVED_SESSION_TOKEN', // fetched from your backend
});
// Bind to a button click
document.getElementById('connect-btn').addEventListener('click', async () => {
try {
const result = await link.connect('salesforce');
console.log('Successfully connected:', result.integrated_account_id);
// Update host application UI and trigger syncs
} catch (err) {
if (err.code === 'USER_CANCELLED') return;
console.error('Auth failed:', err.message);
reportError(err);
}
});Behind this simple interface, the embedded integration platform is doing heavy lifting. The platform handles the entire OAuth lifecycle seamlessly behind the SDK, abstracting away provider-specific token management, refresh logic, and re-authentication flows. The host application never touches the actual third-party access token; it only receives an opaque identifier (integrated_account_id) that it uses to make proxy requests or receive webhooks.
The token used to initialize the SDK should be minted by your backend with a short TTL (a few minutes). Never put long-lived API keys in browser code. The SDK exchanges this token for an authenticated session against the integration platform.
Embeddable Link UI: React and Vanilla JS Integration
The code examples above show the marketplace UI and the raw SDK surface. This section bridges the two: a React hook that wraps the Link SDK lifecycle, and a complete vanilla JS example for teams not using React.
React Hook: useTrutoLink
import { useState, useCallback, useRef } from 'react';
import { TrutoLink } from '@truto/truto-link-sdk';
interface UseTrutoLinkOptions {
onSuccess?: (result: { integrated_account_id: string }) => void;
onError?: (error: { code: string; message: string }) => void;
}
export function useTrutoLink(options: UseTrutoLinkOptions = {}) {
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<{ code: string; message: string } | null>(null);
const linkRef = useRef<TrutoLink | null>(null);
const connect = useCallback(async (integrationId: string) => {
setIsConnecting(true);
setError(null);
try {
// Fetch a short-lived link token from your backend
const res = await fetch('/api/integrations/link-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ integration_id: integrationId }),
});
if (!res.ok) throw new Error('Failed to create link token');
const { token } = await res.json();
// Initialize and open the connect flow
linkRef.current = new TrutoLink({ token });
const result = await linkRef.current.connect(integrationId);
options.onSuccess?.(result);
return result;
} catch (err: any) {
const linkError = { code: err.code || 'UNKNOWN_ERROR', message: err.message };
setError(linkError);
if (err.code !== 'USER_CANCELLED') {
options.onError?.(linkError);
}
throw err;
} finally {
setIsConnecting(false);
}
}, [options]);
return { connect, isConnecting, error, clearError: () => setError(null) };
}Wire this into the IntegrationMarketplace component from the previous section, and you have a complete white-labeled connect flow with loading states, error handling, and user cancellation support.
Vanilla JS: Complete Marketplace With Event Handling
For teams not using React, here is a self-contained vanilla JS implementation:
<div class="tm-marketplace">
<div style="display:flex;gap:1rem;margin-bottom:2rem">
<input type="search" class="tm-search" placeholder="Search integrations..." id="search" />
</div>
<div class="tm-grid" id="grid"></div>
</div>
<script type="module">
import { TrutoLink } from 'https://cdn.truto.one/truto-link-sdk/latest/truto-link.esm.js';
const integrations = await fetch('/api/integrations').then(r => r.json());
function renderGrid(items) {
document.getElementById('grid').innerHTML = items.map(i => `
<div class="tm-card">
<div style="display:flex;align-items:center;justify-content:space-between">
<img src="${i.logo_url}" alt="" class="tm-card-logo" />
${i.status === 'active' ? '<span class="tm-badge tm-badge-success">Connected</span>' : ''}
${i.status === 'needs_reauth' ? '<span class="tm-badge tm-badge-warning">Reconnect</span>' : ''}
</div>
<h3 class="tm-card-title">${i.name}</h3>
<p class="tm-card-desc">${i.description}</p>
<div class="tm-card-footer">
<span class="tm-card-category">${i.category}</span>
<button class="tm-btn tm-btn-primary" data-integration="${i.id}">
${i.status === 'active' ? 'Manage' : i.status === 'needs_reauth' ? 'Reconnect' : 'Connect'}
</button>
</div>
</div>
`).join('');
}
renderGrid(integrations);
// Search filter
document.getElementById('search').addEventListener('input', (e) => {
const q = e.target.value.toLowerCase();
renderGrid(integrations.filter(i => i.name.toLowerCase().includes(q)));
});
// Connect handler via event delegation
document.getElementById('grid').addEventListener('click', async (e) => {
const btn = e.target.closest('[data-integration]');
if (!btn) return;
const id = btn.dataset.integration;
btn.disabled = true;
btn.textContent = 'Connecting...';
try {
const { token } = await fetch('/api/integrations/link-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ integration_id: id }),
}).then(r => r.json());
const link = new TrutoLink({ token });
const result = await link.connect(id);
btn.textContent = 'Connected ✓';
btn.className = 'tm-btn tm-badge-success';
} catch (err) {
btn.disabled = false;
btn.textContent = err.code === 'USER_CANCELLED' ? 'Connect' : 'Retry';
}
});
</script>Both implementations follow the same pattern: fetch a short-lived link token from your backend, initialize the SDK, and call connect(). The SDK handles the popup, the OAuth redirect, the token exchange, and the postMessage callback. Your code just reacts to success or failure.
Example OAuth Implementation: Authorization and Refresh
If you are building the backend that powers your integration marketplace - or evaluating what a managed platform handles for you - these are the three OAuth operations you need to get right. Default to the Authorization Code flow with PKCE; do not use Implicit. The emerging OAuth 2.1 standard mandates PKCE for all clients using the Authorization Code flow.
Step 1: Generate the Authorization URL
import crypto from 'node:crypto';
interface OAuthProviderConfig {
clientId: string;
clientSecret: string;
authorizeUrl: string;
tokenUrl: string;
redirectUri: string;
scopes: string[];
}
async function createAuthorizationUrl(
provider: OAuthProviderConfig,
accountId: string,
stateStore: StateStore
): Promise<string> {
// PKCE: generate code verifier and challenge
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Anti-CSRF: unpredictable state parameter
const state = crypto.randomBytes(32).toString('hex');
// Persist state with short TTL so the callback can validate it
await stateStore.set(state, {
account_id: accountId,
code_verifier: codeVerifier,
created_at: Date.now(),
}, { ttlSeconds: 300 }); // 5-minute window
const params = new URLSearchParams({
response_type: 'code',
client_id: provider.clientId,
redirect_uri: provider.redirectUri,
scope: provider.scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${provider.authorizeUrl}?${params.toString()}`;
}The state parameter and PKCE code verifier are stored together with a 5-minute TTL. The callback handler validates the state, retrieves the code verifier, and deletes the entry - making the state single-use.
Step 2: Handle the Callback and Exchange the Code
interface TokenSet {
access_token: string;
refresh_token?: string;
expires_at: string;
scope?: string;
token_type: string;
}
async function handleOAuthCallback(
code: string,
state: string,
provider: OAuthProviderConfig,
stateStore: StateStore
): Promise<TokenSet> {
// Validate state to prevent CSRF
const storedState = await stateStore.get(state);
if (!storedState) {
throw new Error('Invalid or expired OAuth state');
}
await stateStore.delete(state); // One-time use
// Exchange authorization code for tokens
const tokenResponse = await fetch(provider.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: provider.redirectUri,
client_id: provider.clientId,
client_secret: provider.clientSecret,
code_verifier: storedState.code_verifier, // PKCE proof
}),
});
if (!tokenResponse.ok) {
const err = await tokenResponse.json();
throw new Error(`Token exchange failed: ${err.error_description || err.error}`);
}
const tokens = await tokenResponse.json();
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
const tokenSet: TokenSet = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: expiresAt,
scope: tokens.scope,
token_type: tokens.token_type,
};
// Encrypt and persist tokens, then schedule proactive refresh
await saveEncryptedTokens(storedState.account_id, tokenSet);
await scheduleTokenRefresh(storedState.account_id, expiresAt);
return tokenSet;
}Step 3: Refresh Tokens Before They Expire
async function refreshAccessToken(
accountId: string,
provider: OAuthProviderConfig,
forceRefresh = false
): Promise<TokenSet> {
const currentTokens = await getDecryptedTokens(accountId);
// Check if refresh is needed (with a 30-second buffer)
const expiresAt = new Date(currentTokens.expires_at).getTime();
const isExpired = Date.now() >= expiresAt - 30_000;
if (!isExpired && !forceRefresh) {
return currentTokens; // Token still valid
}
if (!currentTokens.refresh_token) {
throw new Error('No refresh token available - user must re-authorize');
}
const tokenResponse = await fetch(provider.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: currentTokens.refresh_token,
client_id: provider.clientId,
client_secret: provider.clientSecret,
}),
});
if (!tokenResponse.ok) {
const err = await tokenResponse.json();
// Non-retryable: user revoked access or token is invalid
if (err.error === 'invalid_grant') {
await markAccountForReauth(accountId, err.error_description);
throw new Error(`Refresh failed (non-retryable): ${err.error}`);
}
throw new Error(`Refresh failed (retryable): ${err.error}`);
}
const newTokens = await tokenResponse.json();
const updatedTokenSet: TokenSet = {
access_token: newTokens.access_token,
// Some providers rotate refresh tokens on every refresh
refresh_token: newTokens.refresh_token || currentTokens.refresh_token,
expires_at: new Date(Date.now() + newTokens.expires_in * 1000).toISOString(),
scope: newTokens.scope || currentTokens.scope,
token_type: newTokens.token_type,
};
await saveEncryptedTokens(accountId, updatedTokenSet);
await scheduleTokenRefresh(accountId, updatedTokenSet.expires_at);
await reactivateAccountIfNeeded(accountId);
return updatedTokenSet;
}Three things to note in this refresh logic: the 30-second buffer prevents requests from using a token that expires mid-flight, the invalid_grant error is treated as non-retryable (the user revoked access or the refresh token was invalidated), and some providers rotate refresh tokens on every use - so the code always prefers the new refresh token if one is returned.
Client-Side OAuth Trigger
The browser side of the same flow is a thin popup helper. Your host application never handles the authorization code directly; it only kicks off the popup and awaits a postMessage from your OAuth callback origin.
// Client-side helper for triggering the OAuth popup
async function startOAuth(integrationId: string): Promise<{ integrated_account_id: string }> {
// 1. Get a short-lived link token from your backend
const { token } = await fetch('/api/integrations/link-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ integration_id: integrationId }),
}).then((r) => r.json());
// 2. Open popup synchronously (in the click handler) to avoid popup blocking
const popup = window.open('about:blank', 'oauth', 'width=600,height=720');
if (!popup) throw new Error('POPUP_BLOCKED');
// 3. Ask backend for the provider authorization URL, then navigate the popup
const { authorize_url } = await fetch(`/api/oauth/${integrationId}/start?token=${token}`).then((r) => r.json());
popup.location.href = authorize_url;
// 4. Wait for the OAuth callback to postMessage back with the result
return new Promise((resolve, reject) => {
const handler = (event: MessageEvent) => {
// Verify the origin matches your OAuth callback host
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'oauth_result') return;
window.removeEventListener('message', handler);
popup.close();
if (event.data.error) reject(new Error(event.data.error));
else resolve({ integrated_account_id: event.data.integrated_account_id });
};
window.addEventListener('message', handler);
// Handle user closing the popup without completing
const closeCheck = setInterval(() => {
if (popup.closed) {
clearInterval(closeCheck);
window.removeEventListener('message', handler);
reject(new Error('USER_CANCELLED'));
}
}, 500);
});
}The critical detail: window.open must run synchronously inside the click handler, before any await calls. Otherwise Safari and Firefox will block the popup as non-user-initiated.
Credential Vault Patterns and Encryption Examples
Storing OAuth tokens and API keys in plaintext is a straight path to a security incident and a failed SOC 2 audit. Every credential your integration platform accepts - access tokens, refresh tokens, API keys, client secrets, static header values - needs to be encrypted at rest and only decrypted at the moment of use.
Envelope Encryption with a Key Hierarchy
The pattern that scales: use a two-layer key hierarchy. A single Key Encryption Key (KEK) held in a hardware-backed key management service encrypts a per-record Data Encryption Key (DEK). The DEK actually encrypts the credential payload.
Why two layers instead of one:
- Rotating the KEK does not require decrypting and re-encrypting every credential in your database. You only re-wrap the DEKs, which is a metadata operation.
- Audit logs on the KEK give you a single chokepoint for detecting suspicious credential access.
- Plaintext keys never touch your application memory beyond a single request.
flowchart LR
A[KMS - KEK] -->|Unwraps| B[Per-Record DEK]
B -->|AES-GCM Decrypt| C[Plaintext Credential]
C -->|Used for API Call| D[Third-Party API]
D -->|Response| E[Discard Plaintext<br>from Memory]Field-Level Encryption of a Context Blob
Rather than encrypting the entire account row, encrypt specific paths inside a JSON context object. For an OAuth-connected account, the typical protected paths are:
context.oauth.token.access_tokencontext.oauth.token.refresh_tokencontext.oauth.token.access_token_secretcontext.api_key,context.api_token,context.passwordcontext.client_secret,context.secret
Everything else - integration name, status, timestamps, non-sensitive metadata - stays in plaintext so you can index and query on it. When an integrated account is listed via API, the encrypted fields are masked. They are only decrypted when fetched individually and used internally for a specific API call.
AES-256-GCM Encryption Example
Here is a working Node.js implementation using AES-256-GCM with per-record IVs and an authentication tag. GCM is authenticated encryption, which means tampering with the ciphertext is detected on decrypt.
import crypto from 'node:crypto';
import { get, set, cloneDeep } from 'lodash';
// Paths inside the credential context that must be encrypted
const PROTECTED_PATHS = [
'oauth.token.access_token',
'oauth.token.refresh_token',
'oauth.token.access_token_secret',
'api_key',
'api_token',
'password',
'client_secret',
'secret',
];
interface EncryptedField {
ciphertext: string; // base64
iv: string; // base64, 12 bytes for GCM
auth_tag: string; // base64, 16 bytes
wrapped_dek: string; // base64, DEK encrypted by KEK
version: number; // schema version for future rotation
}
// Generate a fresh 32-byte DEK for a single credential value
function generateDek(): Buffer {
return crypto.randomBytes(32);
}
// Wrap the DEK using your KMS. In production this is an API call to AWS KMS,
// GCP KMS, HashiCorp Vault, or your cloud provider's equivalent.
async function wrapDek(dek: Buffer): Promise<string> {
// Placeholder: replace with real KMS.Encrypt call
const kek = Buffer.from(process.env.KEK_MATERIAL!, 'base64');
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', kek, iv);
const ct = Buffer.concat([cipher.update(dek), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, ct]).toString('base64');
}
async function unwrapDek(wrapped: string): Promise<Buffer> {
const kek = Buffer.from(process.env.KEK_MATERIAL!, 'base64');
const buf = Buffer.from(wrapped, 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const ct = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', kek, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]);
}
async function encryptField(plaintext: string): Promise<EncryptedField> {
const dek = generateDek();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
ciphertext: ct.toString('base64'),
iv: iv.toString('base64'),
auth_tag: authTag.toString('base64'),
wrapped_dek: await wrapDek(dek),
version: 1,
};
}
async function decryptField(field: EncryptedField): Promise<string> {
const dek = await unwrapDek(field.wrapped_dek);
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
dek,
Buffer.from(field.iv, 'base64')
);
decipher.setAuthTag(Buffer.from(field.auth_tag, 'base64'));
const pt = Buffer.concat([
decipher.update(Buffer.from(field.ciphertext, 'base64')),
decipher.final(),
]);
return pt.toString('utf8');
}
// Encrypt only the protected paths inside a context object
export async function protectContext(context: Record<string, unknown>) {
const output = cloneDeep(context);
for (const path of PROTECTED_PATHS) {
const value = get(output, path);
if (typeof value === 'string' && value.length > 0) {
set(output, path, await encryptField(value));
}
}
return output;
}
export async function revealContext(context: Record<string, unknown>) {
const output = cloneDeep(context);
for (const path of PROTECTED_PATHS) {
const value = get(output, path) as EncryptedField | undefined;
if (value && typeof value === 'object' && 'ciphertext' in value) {
set(output, path, await decryptField(value));
}
}
return output;
}Key Rotation Playbook
Rotation is not optional. Plan for it before your first customer is live, not during your first security incident.
- Version every encrypted field. The
versionfield above lets you distinguish records encrypted under the old KEK from the new one. - Rotate the KEK in place. Your KMS provides a new KEK version. Existing wrapped DEKs are re-wrapped lazily on next access, or eagerly with a background job.
- Never delete the old KEK immediately. Keep the previous version for the maximum lifetime of any outstanding ciphertext plus a grace period.
- Log every unwrap. Every KEK unwrap is a security event. Route it to your SIEM and alert on unusual patterns.
Do not derive encryption keys from environment variables in production code. Environment variables are exposed in process listings, crash dumps, and log aggregators. Use your cloud provider's KMS, HashiCorp Vault, or a hardware security module. The KEK_MATERIAL example above is illustrative only.
Token Manager Worker: Design and Sample Code
The OAuth examples above handle on-demand refresh ("refresh the token right before making an API call"). But a production integration marketplace also needs proactive refresh - a background worker that refreshes tokens before they expire so that API calls never hit an expired token in the first place.
Long-running processes often run continuously rather than within short-lived user sessions. Background execution through polling loops, schedulers, or event queues happens without direct user interaction. Your token manager needs to handle this without any user present.
Truto's approach: schedule a proactive refresh 60 to 180 seconds before each token's expiry (randomized to spread load), and use a per-account mutex to prevent concurrent refresh attempts from racing each other.
Worker Design at a Glance
flowchart TB
A[Scheduler<br>tick every 60s] --> B{Query accounts<br>expiring in next 5 min}
B --> C[For each account]
C --> D{Acquire per-account<br>mutex lock}
D -->|Locked| E[Wait for<br>in-flight refresh]
D -->|Free| F[Call refresh<br>_token endpoint]
F --> G{Response OK?}
G -->|200 with tokens| H[Persist encrypted tokens<br>Update expires_at<br>Schedule next refresh]
G -->|invalid_grant| I[Mark account<br>needs_reauth<br>Fire webhook]
G -->|5xx or network| J[Classify as retryable<br>Backoff with jitter]
H --> K[Release mutex]
I --> K
J --> KAccount State Machine
stateDiagram-v2
[*] --> connecting: OAuth flow complete
connecting --> active: Post-install actions succeed
connecting --> post_install_error: Post-install action failed
active --> needs_reauth: Token refresh failed<br>(non-retryable)
needs_reauth --> active: User re-authenticates<br>or refresh succeeds
post_install_error --> connecting: Retry post-installWorker Pseudocode
A token manager worker that implements proactive refresh, per-account mutex, and retry classification:
// Token refresh worker - runs on a recurring schedule (e.g., every 60 seconds)
async function tokenRefreshWorker(): Promise<void> {
// Find all tokens expiring in the next 5 minutes
const expiringAccounts = await db.query(`
SELECT id, provider, expires_at
FROM integrated_accounts
WHERE status = 'active'
AND expires_at <= NOW() + INTERVAL '5 minutes'
AND expires_at > NOW()
ORDER BY expires_at ASC
`);
const results = await Promise.allSettled(
expiringAccounts.map((account) =>
refreshWithMutex(account.id, account.provider)
)
);
for (const [i, result] of results.entries()) {
if (result.status === 'rejected') {
const account = expiringAccounts[i];
const decision = classifyRefreshError(result.reason, 1);
if (decision.shouldRetry) {
await scheduleRetry(account.id, decision.delayMs);
}
logger.error(`Refresh failed for ${account.id}: ${result.reason.message}`);
}
}
}
// Mutex: prevent concurrent refreshes for the same account
const refreshLocks = new Map<string, Promise<TokenSet>>();
async function refreshWithMutex(accountId: string, provider: string): Promise<TokenSet> {
const existing = refreshLocks.get(accountId);
if (existing) return existing; // Wait for the in-flight refresh
const refreshPromise = (async () => {
try {
const config = await getProviderConfig(provider);
return await refreshAccessToken(accountId, config, true);
} finally {
refreshLocks.delete(accountId);
}
})();
refreshLocks.set(accountId, refreshPromise);
// Safety valve: force-release the lock if the refresh hangs
setTimeout(() => refreshLocks.delete(accountId), 30_000);
return refreshPromise;
}
// Classify errors: retryable (server issues) vs non-retryable (user action needed)
function classifyRefreshError(
error: Error,
attempt: number
): { shouldRetry: boolean; delayMs: number; reason: string } {
const msg = error.message.toLowerCase();
// Non-retryable: the user must re-authenticate
if (msg.includes('invalid_grant') || msg.includes('consent_required') || msg.includes('unauthorized_client')) {
return { shouldRetry: false, delayMs: 0, reason: 'User must re-authorize' };
}
// Retryable: server errors, rate limits, transient network issues
if (attempt >= 3) {
return { shouldRetry: false, delayMs: 0, reason: 'Max retries exceeded' };
}
// Exponential backoff with jitter
const baseDelay = Math.pow(attempt + 1, 2) * 1000;
const jitter = Math.random() * 1000;
return { shouldRetry: true, delayMs: baseDelay + jitter, reason: 'Transient error' };
}
// Scheduling helper: enqueue a targeted refresh for a single account
async function scheduleTokenRefresh(accountId: string, expiresAt: string): Promise<void> {
const expiresAtMs = new Date(expiresAt).getTime();
// Fire 60-180 seconds before expiry, randomized to spread load
const jitterSeconds = 60 + Math.floor(Math.random() * 120);
const runAtMs = expiresAtMs - jitterSeconds * 1000;
if (runAtMs > Date.now()) {
await scheduler.enqueue('refresh_credentials', {
account_id: accountId,
run_at: new Date(runAtMs).toISOString(),
});
}
}The mutex pattern is important. Without it, if your app has multiple workers or background jobs hitting the API concurrently, two processes might detect the expired token at the same time and both attempt a refresh. The first caller creates the refresh promise, and subsequent callers for the same account await the same promise instead of making duplicate refresh requests. A timeout safety valve (typically 30 seconds) forcibly releases stuck locks.
Never pass the full token object across job boundaries. Pass only the account ID, then look up the latest token from your encrypted store inside the job handler. Otherwise, long-running workers will use stale tokens even after a successful refresh.
Acceptance Criteria for a Production Token Manager
Before this component is production-ready, confirm every item below:
- Proactive refresh fires 60-180 seconds before expiry with jitter to avoid thundering herds.
- On-demand refresh runs before every outbound API call with a 30-second expiry buffer.
- Per-account mutex prevents concurrent refresh attempts from racing.
-
invalid_grant,consent_required, andunauthorized_clienterrors immediately mark the accountneeds_reauthand fire a webhook - no retry attempts. - All other errors retry with exponential backoff and jitter, capped at 3 attempts.
- Refresh token rotation is handled: if the provider returns a new refresh token, it replaces the old one atomically.
- Successful refresh on a
needs_reauthaccount reactivates it and fires a reactivation webhook. - All tokens are stored using the field-level encryption pattern above.
- Every refresh attempt produces a structured log entry with account ID, provider, outcome, and latency.
Transparent Rate Limit Handling in SDKs
A common mistake in SDK design—and where most embedded integration platforms quietly screw their customers—is attempting to be "too helpful" by silently absorbing errors and automatically applying exponential backoff when hitting third-party rate limits.
This is an architectural anti-pattern. When an SDK silently retries a request for 45 seconds, it blocks the host application's execution thread, consumes connection pool resources, and masks underlying architectural bottlenecks. It ships an SDK that appears slow for reasons the implementing engineer cannot debug.
The correct behavior: pass the upstream error through, normalized into IETF standard headers, and let the caller decide how to handle it. Radical transparency always wins over black-box magic.
Truto ensures deterministic API behavior by passing HTTP 429 rate limit errors directly to the caller, following the IETF RateLimit Fields draft. Truto normalizes every provider's rate limit metadata into three headers:
ratelimit-limit: The maximum number of requests permitted in the current window.ratelimit-remaining: The number of requests remaining in the current window.ratelimit-reset: The time at which the current rate limit window resets (in UTC epoch seconds).
When Salesforce returns a 429 because you blew through the daily API quota, Truto returns the 429 directly to your caller. No retries, no silent backoff, no "this request took 47 seconds because we paused it."
try {
const contacts = await truto.unified.crm.contacts.list({ ... });
} catch (err) {
if (err.status === 429) {
const resetSeconds = Number(err.headers['ratelimit-reset']);
// Your retry strategy, not ours
await scheduleRetry(resetSeconds);
}
}By exposing these headers, the SDK empowers the implementing engineer to handle retries using their own background workers, durable state, or scheduling primitives. The SDK abstracts authentication and schema differences; it does not abstract away the fact that Salesforce has a daily API call cap. For a deeper dive on multi-provider retry strategy, see Best Practices for Handling API Rate Limits and Retries.
Webhook Ingestion Sample: Signature Verification and Event Mapping
A white-labeled integration marketplace is not complete without inbound webhooks. When a contact is updated in Salesforce or an employee is terminated in BambooHR, your backend needs to receive that event, verify it is authentic, normalize it to your unified schema, and process it idempotently.
Webhook signature verification is not optional for production systems. The steps are always the same: read the raw request body, extract the signature from the appropriate header, recompute the HMAC using your webhook secret, compare using constant-time equality, and check the timestamp if the provider includes one.
HMAC-SHA256 Verification Middleware
import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
function verifyWebhookSignature(
secret: string,
signatureHeader = 'x-truto-signature',
timestampHeader = 'x-truto-timestamp',
toleranceSeconds = 300
) {
return (req: Request, res: Response, next: NextFunction) => {
const signature = req.headers[signatureHeader] as string;
const timestamp = req.headers[timestampHeader] as string;
const rawBody = (req as any).rawBody as Buffer;
if (!signature || !rawBody) {
return res.status(401).json({ error: 'Missing signature or body' });
}
// Replay attack prevention: reject old timestamps
if (timestamp) {
const eventTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - eventTime) > toleranceSeconds) {
return res.status(401).json({ error: 'Timestamp outside tolerance window' });
}
}
// Compute expected HMAC
const payload = timestamp
? `${timestamp}.${rawBody.toString()}`
: rawBody.toString();
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Constant-time comparison prevents timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
};
}Provider-Specific Verification Formats
Different providers use different signing schemes. A production ingestion layer needs to route by integration name to the right verification format. The four formats you will actually see:
| Format | How it works | Example providers |
|---|---|---|
| HMAC | HMAC over concatenated payload parts, compared against a header | Stripe, GitHub, Shopify |
| JWT | JWT in header, verified with a shared secret or JWK | Slack Events (variant), Zoom |
| Basic | Base64 username:password in Authorization header |
Legacy enterprise APIs |
| Bearer | Static Bearer token compared against configured secret | Custom internal integrations |
All comparisons use timing-safe equality. Placeholders like {{headers.x-signature}} or {{body.token}} are substituted with actual values from the payload before verification, keeping the verification config declarative and provider-specific.
Event Mapping to a Unified Schema
Signature verification tells you the payload is authentic. The next problem: every provider ships webhooks in a different shape. HiBob calls it employee.updated with the employee ID nested under body.employee.id. BambooHR calls it employeeChanged with the ID at the top level. Your application code should not have to care.
The pattern that scales is to declare a per-provider mapping expression that transforms raw webhook payloads into a unified event shape:
interface UnifiedWebhookEvent {
resource: string; // e.g., 'hris/employees', 'crm/contacts'
event_type: 'created' | 'updated' | 'deleted';
raw_event_type: string; // Provider's original event name
method?: 'get' | 'list'; // Follow-up API call to fetch full record
method_config?: { id?: string };
data?: Record<string, unknown>; // Inline data (for deletes or full payloads)
raw_payload: Record<string, unknown>;
}A JSONata-style mapping for HiBob's HRIS webhooks:
// Provider mapping declared once per integration
const hibobEmployeeMapping = `
(
$resource := $split(body.type, '.')[0];
$action := $split(body.type, '.')[1];
$eventType := $lookup({
"created": "created",
"updated": "updated",
"joined": "created",
"left": "updated",
"deleted": "deleted"
}, $action);
$resource = "employee" ? {
"resource": "hris/employees",
"event_type": $eventType,
"raw_event_type": body.type,
"method": $action != "deleted" ? "get",
"method_config": $action != "deleted" ? { "id": body.employee.id },
"data": $action = "deleted" ? body.employee,
"raw_payload": $
}
)
`;
// Applying the mapping
import jsonata from 'jsonata';
async function mapWebhookToUnified(
provider: string,
rawPayload: Record<string, unknown>
): Promise<UnifiedWebhookEvent[]> {
const mapping = await getWebhookMapping(provider);
const expression = jsonata(mapping);
const result = await expression.evaluate({ body: rawPayload });
return Array.isArray(result) ? result : result ? [result] : [];
}Enrichment: Fetch the Full Record
Most webhook payloads only include an ID and event type, not the full record. For a created or updated event, the ingestion layer should call the provider's API to fetch the complete resource and map it through your unified schema. For a deleted event, the payload data is what you have.
async function enrichEvent(
accountId: string,
event: UnifiedWebhookEvent
): Promise<Record<string, unknown>> {
// Delete events already carry the data
if (event.event_type === 'deleted' && event.data) {
return mapToUnifiedSchema(event.resource, event.data);
}
// For created/updated, fetch the full record from the provider
if (event.method === 'get' && event.method_config?.id) {
const record = await unifiedApi.get({
account_id: accountId,
resource: event.resource,
id: event.method_config.id,
});
return record;
}
return event.data ? mapToUnifiedSchema(event.resource, event.data) : {};
}End-to-End Ingestion Handler
import express from 'express';
const app = express();
// Preserve raw body for signature verification
app.use(express.json({
verify: (req: any, _res, buf) => { req.rawBody = buf; },
}));
app.post(
'/webhooks/:provider/:integratedAccountId',
verifyWebhookSignature(process.env.WEBHOOK_SECRET!),
async (req, res) => {
const { provider, integratedAccountId } = req.params;
const eventId = req.headers['x-truto-event-id'] as string;
// Idempotency: skip already-processed events
if (eventId && await eventStore.exists(eventId)) {
return res.status(200).json({ status: 'already_processed' });
}
// Map raw payload to unified events
const unifiedEvents = await mapWebhookToUnified(provider, req.body);
// Enrich and enqueue each event for downstream processing
for (const event of unifiedEvents) {
const record = await enrichEvent(integratedAccountId, event);
await outboundQueue.publish({
event_type: `record:${event.event_type}`,
payload: {
resource: event.resource,
records: [record],
integrated_account_id: integratedAccountId,
raw_event_type: event.raw_event_type,
raw_payload: event.raw_payload,
},
});
}
// Mark event as processed to prevent duplicates
if (eventId) await eventStore.set(eventId, { processed_at: Date.now() });
res.status(200).json({ status: 'processed' });
}
);Three things to get right here: always verify signatures before processing (never skip this, even in development), always use crypto.timingSafeEqual instead of === to prevent timing attacks, and always deduplicate events by ID since webhook delivery is typically at-least-once, not exactly-once.
For compliance-sensitive webhooks, consider having the webhook payload contain only event type and record IDs. Your handler then fetches the full record via an authenticated API call. This minimizes sensitive data in transit and in your webhook logs.
Best Practices for Writing SDK Developer Documentation
Effective SDK documentation must prioritize immediate time-to-value for the implementing engineer. Good SDK docs are not a reference manual. They are a conversion funnel.
Quickstart guides and easy-to-follow code samples can help create a positive developer experience. A quick start guide serves as a brief, focused introduction to your SDK. It aims to take developers from zero to their first successful authentication call as swiftly as possible.
The structure that converts:
- The 60-Second Quickstart (above the fold): A zero-configuration code block demonstrating the absolute minimum required to render the UI and produce a working connect button in under five minutes. Include boilerplate code: Provide snippets of functional code that the developer can copy-paste to see immediate results.
- Authentication Prerequisites: Clear instructions on how to generate the short-lived session token required to initialize the SDK, and where the SDK's session boundary lives.
- Framework Recipes: React, Vue, vanilla JS, and Next.js App Router (because that is what 60% of new B2B SaaS frontends use).
- Configuration Reference & Recipes: A flat table detailing every configuration option. Include recipes like "how to pre-select an integration," "how to filter the integration catalog," and "how to white-label the modal."
- Error Handling & Codes: A comprehensive list of predictable error codes the SDK might throw, what triggers them, and what the user should do.
- Real-world Examples: Full end-to-end repos on GitHub, not just snippets.
- Changelog: APIs are not static; they evolve. Without a clear system for communicating these changes, you risk breaking your users' integrations and eroding their trust. Documenting API versions and maintaining a detailed changelog is a critical best practice that provides stability and transparency.
The most underrated section is a "things you don't have to worry about" page. Explicitly list what the SDK handles for the developer: token refresh, popup blocking, redirect URI mismatch, OAuth state CSRF, and mobile in-app browser quirks. Engineers comparing your SDK to a build-in-house plan will read this page very carefully.
Do not hide your error code reference behind a login wall. The first thing an engineer does when debugging at 2 AM is Google the error message. If your docs are gated, they will end up on Stack Overflow with an outdated answer.
Handling Edge Cases: Native File Pickers and Post-Connection Config
Basic OAuth flows are straightforward. A Link SDK that only handles OAuth is table stakes. The actual hard problems show up after the connection succeeds, testing how the UI handles vendor-specific requirements.
Native File Pickers for Cloud Storage
When connecting to Google Drive, OneDrive, SharePoint, Box, or Dropbox, enterprise users expect to see the native, vendor-provided file selection UI. They want Google's search bar, Microsoft's folder hierarchy, and Box's thumbnail previews. Rebuilding these UIs from scratch is a losing battle against vendor API changes.
Building a unified abstraction that launches the vendor-native UI (not a replica) requires understanding five different SDKs, popup vs inline rendering, OAuth-vs-app-key auth, MessageChannel protocols, and CORS allow-listing in five different developer consoles. We wrote about that mess in The Unholy Mess of File Picker APIs.
Truto's Link SDK exposes this as a single unified function (showFilePicker) that manages all script loading and cross-origin communication behind a consistent API:
import { showFilePicker } from '@truto/truto-link-sdk';
try {
const pickedItems = await showFilePicker(
'googledrive', // or 'sharepoint', 'onedrive', 'box', 'dropbox'
integratedAccountToken,
{
title: 'Select contract documents',
maxItems: 5,
trutoExpression: '$.($merge([{"selected_at": $now()}, $]))'
}
);
console.log('Selected files:', pickedItems);
} catch (error) {
console.error('Picker error:', error);
}Internally, this single function routes to highly complex, provider-specific implementations:
- Google Drive: The SDK dynamically loads multiple scripts (
apis.google.com/js/api.jsandaccounts.google.com/gsi/client), fetches the Drive v3 REST discovery document, and constructs the picker usinggoogle.picker.PickerBuilder. - SharePoint and OneDrive: The SDK opens a blank popup window and submits a form via POST to Microsoft's
FilePicker.aspxendpoint, establishing aMessageChannelprotocol to listen forpickandclosecommands. - Box: The SDK injects Box UI Elements CSS and JavaScript from a CDN and renders an in-page, full-screen modal directly in the DOM.
- Dropbox: The SDK utilizes the Dropbox Chooser, polling the browser for
window.Dropboxreadiness and authenticating via an app key rather than an OAuth token.
Data Transformation at the Edge
Once a user selects files, the resulting data structures vary wildly between providers. To normalize this, advanced Link SDKs allow developers to manipulate the returned data before it hits the host application.
Truto allows post-selection JSONata transformations (trutoExpression) directly within the SDK configuration. You can add timestamps, filter by MIME type, reshape fields, or merge with existing selections before they are persisted. This is the kind of post-selection transform that takes most teams a full sprint to build per provider.
Dynamic Post-Connection Configuration
The second hard problem: many integrations require configuration immediately after the OAuth flow completes. After connecting a CRM, the user must select which pipeline to sync, which custom field maps to your lead_score concept, or which Slack channel should receive notifications.
This UI needs to render dynamically based on data fetched from the freshly connected account. A resilient Link UI supports dynamic post-connection configuration. Instead of forcing the host application to build custom dropdowns for every integration, the SDK should read a JSON schema from the integration platform and dynamically render the necessary form fields, validating the user's input before marking the connection as fully active. For more on designing these interfaces, see our guide on post-connection configuration UI patterns.
flowchart LR
A[OAuth Success] --> B[Fetch Account Metadata]
B --> C[Render Config Form<br>from JSON Schema]
C --> D[User Selects Pipelines<br>Fields Channels]
D --> E[PATCH integrated_account<br>context]
E --> F[Backend Starts Sync]Your backend syncs are now parameterized by user choices without you writing a single line of HubSpot-pipeline-picker code.
Quickstart: Clone & Run the Demo
The fastest way to understand this architecture is to run it. The starter kit ships as a public git repository containing a Next.js host application, an example backend, pre-configured OAuth manifests for Salesforce, QuickBooks, and BambooHR, and a working Link SDK integration you can point at your own sandboxes.
# Clone the starter kit
git clone https://github.com/truto/link-sdk-starter.git
cd link-sdk-starter
# Copy the example env file and fill in your credentials
cp .env.example .env.local
# .env.local expects:
# TRUTO_API_KEY=...
# SALESFORCE_CLIENT_ID=...
# SALESFORCE_CLIENT_SECRET=...
# QUICKBOOKS_CLIENT_ID=...
# QUICKBOOKS_CLIENT_SECRET=...
# BAMBOOHR_SUBDOMAIN=...
npm install
npm run db:migrate # spins up a local SQLite store for account metadata
npm run devOpen http://localhost:3000 and you will see a white-labeled marketplace with three integration cards. Click "Connect" on any card to run the full OAuth flow against the provider sandbox. First successful connection should complete in under three minutes end-to-end.
What the demo actually does when you click Connect:
- The host page calls
POST /api/link-tokenon the demo backend to mint a short-lived link token. - The Link SDK opens a popup and drives the OAuth handshake against the provider sandbox.
- On success, the backend receives the authorization code, exchanges it for tokens, and stores them encrypted.
- The SDK returns an
integrated_account_idto the host page, which flips the card to a "Connected" state. - A background worker starts a proactive refresh timer and immediately kicks off a smoke sync.
The demo is intentionally minimal: no host-app authentication, SQLite for account persistence, console logging for events. It exists to prove the connect flow works, not to be a production template. Everything you would swap in a real deployment (Postgres, a KMS-backed vault, your job queue of choice) is called out with a // TODO(prod) comment in the code.
Included Starter Assets
Every asset in the starter kit is designed to be copied into your own repository and adapted. Nothing is closed-source or hidden behind a paywall.
JSON integration configurations for three common providers, in /configs:
salesforce.json- OAuth 2.0 authorization code with PKCE and refresh token rotation, cursor pagination, unified CRM mappings forcontacts,deals,accounts, andopportunities.quickbooks.json- OAuth 2.0 with per-account company/tenant ID context, unified accounting mappings forinvoices,customers,vendors, andjournal_entries, plus a sandbox vs production base URL toggle.bamboohr.json- API key authentication with subdomain context, unified HRIS mappings foremployees,time_off_requests, andemployment_history, and a custom field discovery step.
Each config is a working example of the same JSON schema the platform uses at runtime. Copy one, change the base URL, mapping expressions, and resource definitions, and you have a new provider - no code deployment required.
OAuth app manifests and setup instructions in /oauth-setup, one Markdown file per provider:
salesforce.md- Steps to create a Connected App in Setup > App Manager, required scopes (api refresh_token offline_access), callback URL configuration, and how to enable the OAuth policies for the app.quickbooks.md- Steps for the Intuit Developer portal, sandbox company creation, redirect URI whitelisting, and switching between sandbox and production tokens.bamboohr.md- Steps to generate an API key from Account > API Keys, subdomain discovery, and how to scope the API user for read-only versus read-write access.
Each manifest is a numbered checklist an onboarding engineer can execute in under 15 minutes per provider. Screenshots included, updated for the 2026 provider dashboards.
Sample field-mapping templates in /mappings, ready to drop into an environment or account override:
salesforce-custom-fields.jsonata- Discovers custom fields ending in__cand exposes them under acustomobject on the unified contact.quickbooks-tax-normalization.jsonata- Normalizes QuickBooks tax line items into a flattax_amountandtax_rateon the unified invoice.bamboohr-employment-status.jsonata- Maps BambooHR's raw employment status codes to the unifiedactive | terminated | on_leaveenum.
A minimal demo host application in /apps/demo:
- Next.js 14 App Router with TypeScript.
- White-labeled marketplace grid using the CSS custom properties described earlier in this guide.
- Link SDK wired up with success and error callbacks.
- Backend routes for link token minting, OAuth callback handling, and webhook ingestion with signature verification.
- SQLite persistence for connected accounts (swap for Postgres by pointing
DATABASE_URLat your own instance).
The demo host is under 500 lines of code, fully typed, and licensed permissively so you can fork it into a private repo without attribution requirements.
Field Mapping & Override Examples
Field mapping is where a white-label integration marketplace either delights enterprise customers or drives them to rebuild it in-house. The pattern that scales: a three-level override hierarchy so no customer ever needs to fork the platform to get their custom fields exposed.
Level 1: Platform default applies to every customer. This is the baseline mapping shipped in configs/salesforce.json:
/* Salesforce contact -> unified contact (platform default) */
{
"id": Id,
"first_name": FirstName,
"last_name": LastName,
"email": Email,
"phone_numbers": [
{ "type": "work", "value": Phone },
{ "type": "mobile", "value": MobilePhone }
],
"created_at": CreatedDate,
"updated_at": LastModifiedDate
}Level 2: Environment override adds a customer-specific custom field without touching the platform default. Stored on the environment's mapping table and deep-merged onto the platform default at request time:
{
"response_mapping": {
"additions": {
"lead_score": "Lead_Score__c",
"account_tier": "Account_Tier__c"
}
}
}Every other field continues to map exactly as before. The customer gets two new fields on their unified contact response without a code change.
Level 3: Account override handles a single connected account's oddities without polluting the environment config. Stored on the integrated_account record itself:
{
"response_mapping": {
"additions": {
"region": "Custom_Region__c"
}
},
"query_mapping": {
"filter": "Account.Owner.UserRole.Name = 'EMEA'"
}
}Merging is deterministic: platform default, then environment, then account, with arrays overwritten rather than concatenated. The starter kit includes a working example in /mappings/example-override.json showing all three levels stacked together for a real Salesforce customer scenario.
Common override patterns bundled with the starter kit:
| Pattern | File | What it does |
|---|---|---|
Expose all __c custom fields |
salesforce-custom-fields.jsonata |
Iterates response fields, filters those ending in __c, nests them under custom |
| Normalize tax lines | quickbooks-tax-normalization.jsonata |
Sums nested tax detail arrays into flat totals on the unified invoice |
| Map enum vocabularies | bamboohr-employment-status.jsonata |
Translates provider-specific status strings into your unified enum |
| Route to sandbox endpoint | example-sandbox-route.json |
Account-level override pointing base_url at a provider's sandbox host |
| Custom filter parameter | example-custom-filter.json |
Query mapping override to expose a provider-specific filter your unified schema does not have |
Every pattern above is a single JSON or JSONata file. No compilation, no deployment, no code review - you paste it into the appropriate override column and the runtime picks it up on the next request.
3-Week Sprint Checklist
If you are scoping a marketplace prototype right now, the following is a realistic three-week plan for a two-engineer team (one backend, one frontend). Adjust for team size, but do not skip milestones.
Week 1: Foundation and first OAuth flow
| Day | Milestone | Owner |
|---|---|---|
| Mon | Fork starter kit, provision Salesforce/QuickBooks/BambooHR sandboxes | Backend |
| Tue | Create OAuth apps in all three provider dashboards using /oauth-setup guides |
Backend |
| Wed | End-to-end OAuth flow for Salesforce running in local dev | Backend + Frontend |
| Thu | Encrypted token storage wired up, proactive refresh worker scheduled | Backend |
| Fri | Marketplace grid renders three providers, Salesforce connect button works | Frontend |
Week 2: Multi-provider and configuration
| Day | Milestone | Owner |
|---|---|---|
| Mon | QuickBooks and BambooHR wired end-to-end using the shared config schema | Backend |
| Tue | One environment-level field-mapping override tested against a real customer scenario | Backend |
| Wed | Post-connection configuration screen (pipeline picker or field selector) | Frontend |
| Thu | Webhook ingestion with signature verification for one provider | Backend |
| Fri | Connected account dashboard: status, last sync, reauth prompt | Frontend |
Week 3: Hardening and go-live prep
| Day | Milestone | Owner |
|---|---|---|
| Mon | Structured logging with correlation IDs on every route | Backend |
| Tue | Alert rules deployed for refresh failures and webhook signature failures | Backend + SRE |
| Wed | Smoke test suite passing against all three providers | Backend |
| Thu | White-label theming applied for the pilot customer's brand | Frontend |
| Fri | Internal demo to sales, security review kickoff, roadmap for provider #4+ | Engineering lead |
Ship a prototype in three weeks or find out early which assumption is wrong. Longer timelines almost always hide a misunderstood provider quirk that will surface anyway - better to hit it in week two than week eight.
Milestones a stakeholder outside engineering should see:
- End of week 1: a video of someone clicking Connect on Salesforce and seeing "Connected" in the host app.
- End of week 2: three providers connectable, one custom field mapped for a named pilot customer.
- End of week 3: dashboards showing account status, alert runbooks linked, security review scheduled.
Testing and Smoke Tests
Before every deployment, run the smoke test sequence bundled with the starter kit. Automate what you can; manual-test what you cannot.
1. OAuth round-trip test per provider
npm run smoke:oauth -- --provider=salesforce
npm run smoke:oauth -- --provider=quickbooks
npm run smoke:oauth -- --provider=bamboohrThe script mints a link token, drives a headless browser via Playwright through the OAuth flow using a stored test-user credential, and asserts that:
- A new
integrated_accountrow is created with statusactive. - Access and refresh tokens are stored using field-level encryption.
- A proactive refresh job is scheduled before the token's
expires_at. - The
integrated_account:createdwebhook fires to a test endpoint within 5 seconds.
2. Refresh worker test
Fast-forward the mock clock (or manually set expires_at to 30 seconds in the future) and verify the token refreshes without any user-visible request failing. Assert that the new access token value is different from the old one and that the encrypted database column updates atomically.
3. Webhook ingestion test
Send synthetic webhook payloads to /api/webhooks/:provider/:accountId for each provider with valid and invalid signatures. Assert:
- Valid signatures return
200and enqueue a normalized event. - Invalid signatures return
401and enqueue nothing. - Duplicate event IDs return
200but do not enqueue a second copy. - Timestamps outside the tolerance window are rejected as replays.
4. Unified API round-trip
For each provider, list the primary resource (contacts, invoices, employees), get one by ID, and assert the response conforms to your unified schema. Include a case with a custom field override applied at the environment level to verify the merge pipeline.
5. Rate limit surfacing
Trigger a 429 from a sandbox that supports quota injection. Assert that the SDK response includes ratelimit-limit, ratelimit-remaining, and ratelimit-reset headers, and that the platform did not silently retry the failed request.
6. Credential rotation drill
Rotate the KEK in your KMS staging environment. Assert that:
- Existing accounts continue to work (lazy re-wrap on next access).
- The rotation log records every unwrap under the old KEK version.
- A background job can eagerly re-wrap all DEKs without downtime.
Smoke test output
Save the smoke test JSON output as a CI artifact on every run. When a provider quietly changes an OAuth response shape or a webhook signature format, this is the evidence you need to open a support ticket - and to prove your customer's failure was not caused by your deploy.
The starter kit's /tests/smoke directory contains Playwright scripts, mock webhook generators, and a KMS rotation harness. All tests run against the provider sandboxes using isolated test accounts, so you can execute the full suite from your laptop without touching production data.
Production Readiness: Logging, Alerts, and SLAs
Shipping a Link SDK to production is where most teams under-invest. The OAuth flow works on day one. It is day 90, when a provider rotates a signing key and 200 accounts silently start failing, that separates production-grade integrations from demos. This section is the checklist you actually need before your first paying customer runs a sync at 3 AM.
Structured Logging with Correlation IDs
Every log line touching the integration layer should include a stable set of fields. This lets you slice failures by provider, account, environment, and request across every log aggregator.
logger.info({
event: 'oauth.token.refreshed',
correlation_id: req.headers['x-correlation-id'] || crypto.randomUUID(),
integrated_account_id: account.id,
environment_id: account.environment_id,
provider: account.provider,
auth_type: account.authentication_method,
outcome: 'success',
latency_ms: Date.now() - startedAt,
attempt: 1,
expires_in_seconds: newToken.expires_in,
});Minimum fields to include on every integration event:
correlation_id- propagated from the initial customer request through every downstream callintegrated_account_idandenvironment_id- for multi-tenant filteringprovider- the integration name (salesforce,hubspot, etc.)event- a dot-separated event name (oauth.token.refreshed,webhook.received,api.call.failed)outcome-success,retryable_error,non_retryable_errorlatency_ms- end-to-end durationerror.codeanderror.message- on failure only, and never the raw token or refresh token
Never log raw access tokens, refresh tokens, API keys, or PII from the third-party response. Truncate or hash if you need a value for correlation.
Metrics to Track
| Metric | Type | Why it matters |
|---|---|---|
oauth_token_refresh_total{provider, outcome} |
Counter | Baseline refresh volume and failure rate |
oauth_token_refresh_duration_seconds{provider} |
Histogram | Detect slow OAuth token endpoints (Salesforce, NetSuite) |
integrated_account_status{status} |
Gauge | Count of accounts in active, needs_reauth, post_install_error |
webhook_ingest_total{provider, outcome} |
Counter | Signature failures, mapping errors, delivery success |
webhook_ingest_duration_seconds{provider} |
Histogram | Slow webhook handlers block third-party retry logic |
unified_api_request_total{provider, resource, status} |
Counter | Third-party 4xx/5xx rates per resource |
unified_api_request_duration_seconds |
Histogram | Latency budget per unified endpoint |
rate_limit_hits_total{provider} |
Counter | Detect when a provider quota is being exhausted |
Sample Alert Rules
These are PromQL-style rules; translate to CloudWatch, Datadog, or your platform of choice.
groups:
- name: integrations
rules:
# Refresh failure rate above 5% over 15 minutes
- alert: OAuthRefreshFailureRateHigh
expr: |
sum(rate(oauth_token_refresh_total{outcome="non_retryable_error"}[15m])) by (provider)
/
sum(rate(oauth_token_refresh_total[15m])) by (provider)
> 0.05
for: 15m
annotations:
summary: "{{ $labels.provider }} refresh failure rate above 5%"
runbook: "Check for provider auth changes or revoked OAuth app credentials."
# Sudden spike in accounts marked needs_reauth
- alert: AccountsNeedingReauthSpike
expr: |
increase(integrated_account_status{status="needs_reauth"}[1h]) > 20
for: 5m
annotations:
summary: "20+ accounts entered needs_reauth in the last hour"
runbook: "Likely provider-side change. Verify OAuth app is still registered."
# Webhook signature failures indicate a rotated secret or an attack
- alert: WebhookSignatureFailuresHigh
expr: |
sum(rate(webhook_ingest_total{outcome="signature_invalid"}[10m])) by (provider) > 1
for: 10m
annotations:
summary: "{{ $labels.provider }} webhook signatures failing"
runbook: "Verify the webhook secret matches the provider dashboard."
# Rate limit exhaustion on a specific provider
- alert: RateLimitBurn
expr: |
sum(rate(rate_limit_hits_total[5m])) by (provider) > 0.5
for: 10m
annotations:
summary: "{{ $labels.provider }} 429 rate exceeds 30/min"
runbook: "Review sync cadence or request a quota increase."
# Token refresh latency degrading
- alert: RefreshLatencyDegraded
expr: |
histogram_quantile(0.99, rate(oauth_token_refresh_duration_seconds_bucket[10m])) > 5
for: 15m
annotations:
summary: "P99 refresh latency above 5s"
runbook: "Provider token endpoint may be degraded. Check status page."Recommended SLO Targets
Use these as starting points and tighten them once you have baseline data.
| Surface | SLO | Rationale |
|---|---|---|
| OAuth callback endpoint | 99.9% availability, P95 < 2s | Failed callback = failed customer connection |
| Token refresh (proactive) | 99.5% success rate over 24h | Failures cascade into user-visible API errors |
| Unified API read requests | 99.9% availability, P95 < 500ms platform overhead | Excludes third-party latency |
| Webhook ingestion (200 to third-party) | 99.95% availability, P95 < 1s | Third-party retry policies punish slow endpoints |
| Webhook to customer delivery | At-least-once, P95 delivery < 30s | Async is fine; silent drops are not |
| Credential decryption | 99.99% availability | KMS is on the hot path for every outbound call |
Acceptance Criteria for Production Launch
Before your first enterprise customer relies on this platform:
- Every OAuth provider has been tested end-to-end in a staging environment with a real customer sandbox.
- Refresh token rotation has been observed and handled correctly for every provider that rotates.
- Credentials are encrypted at rest with envelope encryption; the KEK is held in a hardware-backed KMS.
- Every log line is free of tokens, refresh tokens, API keys, and PII from third-party responses.
- Webhook signature verification uses
crypto.timingSafeEqualon every path. - Webhook handlers respond to the third-party within 2 seconds; heavy processing happens on a queue.
- Idempotency keys are enforced on outbound webhook delivery to your customers.
- Rate limit responses (429) are surfaced to callers with normalized
ratelimit-*headers. -
needs_reauthstate fires a webhook to your application within 30 seconds of the failure. - Every alert has a linked runbook that a mid-level engineer can execute at 3 AM.
- Disaster recovery: encrypted credential backups are tested for restore quarterly.
- SOC 2 Type II report is available for prospect security reviews.
- Data residency: customers can pin their environment to a specific region if required.
The Real Trade-Offs of Buying an Embedded Link SDK
Your engineering team's time is your most constrained resource. Spending sprints building bespoke OAuth popups, deciphering Microsoft's file picker documentation, and writing retry logic for undocumented rate limits is a strategic error. However, radical honesty dictates that an embedded Link SDK is not a magic bullet.
Three things to plan for when moving away from in-house builds:
- OAuth app ownership: If you use a vendor's shared OAuth app, switching providers later means re-authenticating every customer. Pick a Link SDK that lets you bring your own OAuth credentials per provider. We wrote about the trap in OAuth App Ownership.
- UI customization ceiling: Even the most flexible SDK constrains your design system. If your CEO wants the connect flow to feel "completely native," budget a week to theme it properly and accept that some provider screens (like Google's consent screen or Microsoft's File Picker) are owned by the provider and cannot be restyled.
- Security review surface: Enterprise prospects will ask where the tokens live, who can decrypt them, and what your data residency story is. Pick a vendor with SOC 2 Type II, configurable data regions, and a clean answer to "do you store our customers' API responses by default?"
For the full build-vs-buy math, see Build vs Buy: The True Cost of Building SaaS Integrations In-House.
Next Steps for Shipping Your Link SDK
If you are scoping this work right now, here is the order of operations that minimizes risk:
- Map your top 5 integrations by deal-blocking frequency. Not by what your engineers find interesting—by what sales loses deals over.
- Decide the auth model. Bring-your-own OAuth credentials (recommended for enterprise) or shared (faster to ship, painful to migrate).
- Spike the Link SDK against one integration end-to-end. Test OAuth, post-connection config, and one data sync. Time-box this to a week.
- Write the docs before you write the marketing page. If your quickstart cannot get an engineer to a working connect button in 5 minutes, fix the SDK, not the copy.
- Instrument the funnel. Track connect-button-clicked, OAuth-redirect-completed, post-config-saved, and first-sync-succeeded. The drop-off between steps tells you exactly where the SDK fails customers.
By adopting an embeddable Link SDK, you offload the authentication lifecycle, UI edge cases, and token management to dedicated infrastructure. You give your implementing engineers a clean API surface with predictable error handling. Most importantly, you give your sales team the ability to say "yes" to enterprise integration requests without derailing your core product roadmap.
A Link SDK is the highest-leverage component in your integration stack. Spend a sprint on it, not a quarter. And if you would rather not own the file picker matrix, the OAuth refresh edge cases, or the next time Microsoft renames its identity platform, that is exactly what a managed unified API platform is for.
Further Reading & Next Steps
The starter kit above is a working reference implementation you can fork today. When you outgrow it, the following posts cover the next layer of decisions in more depth.
Architecture and strategy:
- What is a Customer-Facing Integration? - the architectural difference between internal automations and a customer-facing marketplace.
- Build vs Buy: The True Cost of Building SaaS Integrations In-House - the full economics of the decision, with numbers.
- OAuth App Ownership - why you should own your OAuth app credentials from day one.
Deep dives on components you saw in this guide:
- Best Practices for Handling API Rate Limits and Retries - multi-provider retry strategy without silently blocking the caller.
- The Unholy Mess of File Picker APIs - why native file pickers deserve their own subsystem.
- Dynamic Post-Connection Configuration - rendering setup UI from JSON schema after OAuth completes.
- Post-Connection Configuration UI Patterns for SaaS Integrations - concrete UX patterns for the pipeline picker, field selector, and channel mapper.
- Developer Quickstart: Link UI + Unified Webhooks for B2B SaaS - the companion quickstart focused on inbound event handling.
What to do next:
- Clone the starter kit and complete the Salesforce end-to-end flow on your laptop.
- Copy one config JSON into your own repository and change the base URL to point at your first real customer's sandbox.
- Run the smoke test suite and pin the JSON output to your first internal demo document.
- Book time with your security team using the Production Readiness checklist as the agenda.
FAQ
- What is a Link SDK in a SaaS integration platform?
- A Link SDK is an embeddable JavaScript or native mobile component that renders the authentication UI for third-party integrations directly inside your product. It handles OAuth redirects, token storage, error recovery, and post-connection configuration through a single function call.
- How much does it cost to build custom integration infrastructure?
- Organizations typically spend $30,000 to $500,000+ just for the initial build of custom integration infrastructure, with annual maintenance running $50,000 to $150,000 once you factor in token refresh edge cases, provider auth changes, and file picker UIs.
- How should SDKs handle third-party API rate limits?
- SDKs should not silently absorb errors with automatic retries. They should pass HTTP 429 errors directly to the caller alongside standardized IETF rate limit headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) to allow the host application to manage its own backoff logic.
- How do you handle native file pickers like Google Drive and SharePoint in a unified SDK?
- A well-designed Link SDK exposes a single function that internally routes to the vendor-native picker UI for each provider. The SDK handles script loading, OAuth token injection, popup vs inline rendering, and post-selection persistence, so engineers do not have to learn multiple vendor SDKs.
- What should SDK developer documentation include to maximize conversion?
- Start with a quickstart that produces a working integration in under five minutes using copy-pasteable boilerplate. Add authentication prerequisites, framework-specific recipes, a complete error code reference, real-world example repos, and a published changelog. Never gate the error reference behind a login wall.