Skip to content

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.

Uday Gajavalli Uday Gajavalli · · 49 min read
How to Build and Document a High-Converting Link SDK for SaaS Integrations

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.

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 & L

Key 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 MessageChannel API or postMessage.
  • 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.open calls 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_grant because 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.read to 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.
Tip

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:

  1. One initialization function. Not a builder, not a factory, not a config DSL. They want a single function call.
  2. Promises over callbacks. Modern engineers expect await link.connect(...).
  3. 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.
  4. Predictable errors. Throw typed errors with stable error codes (USER_CANCELLED, POPUP_BLOCKED, INVALID_SCOPE, TOKEN_EXPIRED), not stringly-typed exceptions.
  5. 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.

Tip

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.

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.

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_token
  • context.oauth.token.refresh_token
  • context.oauth.token.access_token_secret
  • context.api_key, context.api_token, context.password
  • context.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.

  1. Version every encrypted field. The version field above lets you distinguish records encrypted under the old KEK from the new one.
  2. 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.
  3. Never delete the old KEK immediately. Keep the previous version for the maximum lifetime of any outstanding ciphertext plus a grace period.
  4. Log every unwrap. Every KEK unwrap is a security event. Route it to your SIEM and alert on unusual patterns.
Warning

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 --> K

Account 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-install

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

Warning

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, and unauthorized_client errors immediately mark the account needs_reauth and 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_reauth account 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.

Tip

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:

  1. 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.
  2. 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.
  3. Framework Recipes: React, Vue, vanilla JS, and Next.js App Router (because that is what 60% of new B2B SaaS frontends use).
  4. 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."
  5. Error Handling & Codes: A comprehensive list of predictable error codes the SDK might throw, what triggers them, and what the user should do.
  6. Real-world Examples: Full end-to-end repos on GitHub, not just snippets.
  7. 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.

Warning

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.js and accounts.google.com/gsi/client), fetches the Drive v3 REST discovery document, and constructs the picker using google.picker.PickerBuilder.
  • SharePoint and OneDrive: The SDK opens a blank popup window and submits a form via POST to Microsoft's FilePicker.aspx endpoint, establishing a MessageChannel protocol to listen for pick and close commands.
  • 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.Dropbox readiness 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 dev

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

  1. The host page calls POST /api/link-token on the demo backend to mint a short-lived link token.
  2. The Link SDK opens a popup and drives the OAuth handshake against the provider sandbox.
  3. On success, the backend receives the authorization code, exchanges it for tokens, and stores them encrypted.
  4. The SDK returns an integrated_account_id to the host page, which flips the card to a "Connected" state.
  5. 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 for contacts, deals, accounts, and opportunities.
  • quickbooks.json - OAuth 2.0 with per-account company/tenant ID context, unified accounting mappings for invoices, customers, vendors, and journal_entries, plus a sandbox vs production base URL toggle.
  • bamboohr.json - API key authentication with subdomain context, unified HRIS mappings for employees, time_off_requests, and employment_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 __c and exposes them under a custom object on the unified contact.
  • quickbooks-tax-normalization.jsonata - Normalizes QuickBooks tax line items into a flat tax_amount and tax_rate on the unified invoice.
  • bamboohr-employment-status.jsonata - Maps BambooHR's raw employment status codes to the unified active | terminated | on_leave enum.

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_URL at 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=bamboohr

The 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_account row is created with status active.
  • 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:created webhook 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 200 and enqueue a normalized event.
  • Invalid signatures return 401 and enqueue nothing.
  • Duplicate event IDs return 200 but 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.

Info

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 call
  • integrated_account_id and environment_id - for multi-tenant filtering
  • provider - 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_error
  • latency_ms - end-to-end duration
  • error.code and error.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."

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.timingSafeEqual on 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_reauth state 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.

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:

  1. 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.
  2. 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.
  3. 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.

If you are scoping this work right now, here is the order of operations that minimizes risk:

  1. Map your top 5 integrations by deal-blocking frequency. Not by what your engineers find interesting—by what sales loses deals over.
  2. Decide the auth model. Bring-your-own OAuth credentials (recommended for enterprise) or shared (faster to ship, painful to migrate).
  3. 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.
  4. 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.
  5. 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:

Deep dives on components you saw in this guide:

What to do next:

  1. Clone the starter kit and complete the Salesforce end-to-end flow on your laptop.
  2. Copy one config JSON into your own repository and change the base URL to point at your first real customer's sandbox.
  3. Run the smoke test suite and pin the JSON output to your first internal demo document.
  4. 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.

More from our Blog