How to Pull User Lists From 100+ SaaS APIs Without Point-to-Point Code
Learn how to architect a Unified Directory API to automate user access reviews across hundreds of SaaS apps without maintaining individual point-to-point connectors.
If your engineering team is tasked with building a feature to audit, sync, or review user access across your customers' SaaS stacks, you are looking at a massive architectural hurdle. B2B software buyers expect your platform to automatically discover who has access to their tools. If your roadmap says "let customers pull user lists, roles, and access levels from every SaaS app they use," you have two options: burn years of engineering time building 100+ point-to-point connectors, or route every provider through a single unified directory schema and ship in weeks.
You are sitting in a sprint planning meeting. Your product manager drops a seemingly simple requirement in the PRD: "Our customers need to pull a list of users, roles, and permissions from all the SaaS apps they connect so we can run automated access reviews."
Then you open a spreadsheet of target apps and start counting.
According to BetterCloud's 2024 SaaS statistics report, the average company uses 106 different SaaS applications. Realistically, one engineer can ship maybe two to four production-grade integrations per quarter once you factor in OAuth flows, pagination quirks, rate limits, retries, webhook receivers, token refreshes, error normalization, and the six weeks of monitoring after launch when the vendor silently changes a response shape.
Do the math. A four-engineer team, generously producing 16 integrations per year, needs roughly six years to cover the average customer's stack. And that stack keeps growing. By the time you finish Okta, Google Workspace, GitHub, Slack, Jira, Notion, and Salesforce, your customers are asking for Linear, Attio, Loom, and three vertical tools you have never heard of. Building point-to-point connectors for over a hundred platforms means this feature will sit on the roadmap for years. You are staring down terrible vendor API documentation, aggressive rate limits, and undocumented edge cases.
This guide breaks down why traditional identity syncing falls short, how to architect a scalable extraction pipeline, and how to use a unified directory API to solve the identity sprawl problem without writing integration-specific code.
The Engineering Trap of Point-to-Point Directory Integrations
Identity sprawl is directly breaking product roadmaps and introducing massive security risks. Customers expect your platform to provide total visibility into who has access to what, but the sheer volume of integrations required makes it an engineering nightmare.
Building individual connectors for every SaaS tool your customer uses is an unscalable roadmap trap. Every third-party API has its own authentication quirks, pagination strategies, and data models. Jira paginates differently than Salesforce. Slack represents user states differently than HubSpot. When you build point-to-point integrations, your team absorbs the maintenance burden for every single one of these quirks.
The security stakes are also getting louder. Tripwire's 2025 State of SaaS Security report highlights the exact fallout of this integration gap. They found that 58% of organizations are unable to enforce least privilege consistently, and 54% lack automation for provisioning and deprovisioning. Even worse, 41% of SaaS-related breaches trace back to overprivileged accounts. SpyCloud's 2025 Identity Exposure Report puts identity incident rates at 91% of organizations in the past year—nearly double the year prior.
Your customers know this. That is why they want the feature. But the answer is not to hand-roll 100 API clients. If you are building a compliance, GRC, or security platform, your core value proposition relies on fetching accurate user lists. You cannot afford to spend six months building connectors just to reach baseline functionality. You need an architectural approach that scales instantly. For a deeper look at why bespoke connectors fail at scale, read our guide on scaling GRC integrations.
Why SCIM and Okta Aren't Enough for Complete Visibility
The standard engineering reflex is usually, "just integrate with the IdP." Connect to Okta, Microsoft Entra ID, Google Workspace, or JumpCloud—let customers push data via SCIM (System for Cross-domain Identity Management) and call it done.
This approach fails upon contact with reality. Connecting to major Identity Providers (IdPs) is table stakes, but it leaves a massive blind spot.
A directory integration is an automated connection that synchronizes user accounts, groups, roles, and lifecycle events between systems. While SCIM handles provisioning for supported apps, it does not provide a complete picture of a company's SaaS landscape. If you rely purely on SCIM and central IdPs, you will encounter four major failure modes:
- The Shadow IT Problem: Grip Security data shows that up to 90% of SaaS applications and AI tools operate entirely outside of centralized IT oversight. Cards get expensed, Slack workspaces get spun up, someone's team signs up for a design tool with a personal email. If an app is not connected to Okta, your SCIM integration will never see it.
- The SCIM Tax: SCIM coverage is patchy. Many SaaS vendors lock SCIM support behind their highest enterprise pricing tiers. Mid-market companies often use the standard tiers of these products, meaning they authenticate via standard OAuth or SAML but cannot use SCIM for automated user management.
- Granular Permissions and App Reality: SCIM is generally good at answering "does this user exist?" It is notoriously bad at answering "what specific granular permissions does this user have within the third-party application?" A user provisioned via SCIM three months ago may have been made an admin manually inside the app, added to a private group, or granted elevated custom roles that the IdP never sees.
- Deactivation Doesn't Equal Deletion: SCIM deprovisioning frequently just flips a flag. The underlying data—group memberships, file ownership, API tokens—stays behind.
To build a true SaaS user access review API or security feature, you have to go straight to the source. You must pull user lists directly from the underlying vendor APIs. That means pulling users from Slack's users.list, GitHub's org members endpoint, Salesforce's User SObject, Notion's users API, and 90 others. SSO and SCIM are complementary primitives, not a substitute for reading source-of-truth data from the app itself. We unpack this distinction in more depth in what directory integrations are.
Relying solely on central IdPs for access reviews guarantees an incomplete audit. You must extract user directories directly from the end-state SaaS applications to capture shadow IT and granular role assignments.
Architecting a Unified Directory API Solution
To avoid building 100 individual connectors, you must abstract the integration layer. The scalable architecture inverts the problem. Instead of writing one integration client per vendor, you define a common data model once, and describe each provider declaratively—endpoints, auth type, pagination style, field mappings—as configuration.
A Unified Directory API is an architectural layer that normalizes the user, group, and role endpoints across hundreds of disparate SaaS applications into a single, standardized data model.
Instead of writing custom HTTP clients for Salesforce, Slack, and Jira, your application makes a single request to the unified API. The core idea: there is no integration-specific code path. A request to GET /users for Slack and a request to GET /users for Salesforce hit the same runtime.
flowchart LR
App["Your SaaS<br>App"] --> API["Unified<br>Directory API"]
subgraph TrutoPlatform ["Truto Execution Pipeline"]
API --> Engine["Generic Execution<br>Engine"]
Engine --> Config["Per-Provider<br>Config (JSON)"]
Engine --> Auth["OAuth / API Key<br>Token Manager"]
Engine --> Norm["Schema<br>Normalizer"]
end
Norm --> Slack["Slack API"]
Norm --> GH["GitHub API"]
Norm --> SF["Salesforce API"]
Norm --> Others["90+ others"]This architecture fundamentally changes how your engineering team operates. Adding a new connector is a data-only operation: write the config, ship. No new deployments, no branching logic in the request handler, no if provider == 'slack' scattered through the codebase.
A simplified configuration entry mapping the provider into the unified layer might look like this:
{
"provider": "slack",
"resource": "users",
"method": "list",
"http": {
"method": "GET",
"path": "/users.list",
"pagination": {
"type": "cursor",
"cursor_param": "cursor",
"cursor_path": "response_metadata.next_cursor",
"limit_param": "limit"
}
},
"mapping": {
"id": "id",
"email": "profile.email",
"display_name": "real_name",
"is_active": "!deleted",
"is_admin": "is_admin"
}
}The caller never sees this. They call one endpoint—GET /unified/directory/users?integrated_account_id=...—and get a normalized payload back, regardless of which app is on the other side.
Behind the scenes, the platform manages the complexity of third-party authentication. For example, Truto handles the OAuth token lifecycle automatically, refreshing tokens shortly before they expire. A long-running bulk pull won't die halfway through with a 401 Unauthorized error, and your application simply uses a stable bearer token to interact with the unified layer.
Normalizing Users, Groups, Roles, and Licenses
A unified directory API is only as good as its data model. Pulling a list of names is only the first step. True access reviews require understanding the relationships between users, their organizational units, and their specific permissions. If the schema forces every provider into a shape that doesn't match reality, you will spend just as much time wrestling normalization edge cases as you would writing bespoke code.
At minimum, a directory-focused unified API should standardize these core entities:
| Entity | What it represents | Typical fields |
|---|---|---|
| User | A person or service account in the app | id, email, display_name, status, last_login_at |
| Group | A team, channel, or department construct | id, name, description, member_ids |
| Role | A permission set defined in the app | id, name, permissions [], is_system |
| RoleAssignment | Which user has which role, optionally scoped | user_id, role_id, scope, granted_at |
| License | A paid seat or subscription tier assigned to a user | id, user_id, sku, assigned_at |
| Utilization | Usage signals like last login or session count | user_id, last_active_at, session_count_30d |
| Activity | Real-time audit events for logins or admin changes | id, actor_id, action, target, timestamp |
This set covers the four primary use cases most teams need:
- Automated onboarding and offboarding: Create users, assign groups and licenses on day one; suspend and revoke on exit.
- Centralized RBAC: Fetch available roles per app, apply role assignments, enforce least privilege.
- License optimization: Cross-reference Licenses with Utilization to find dormant seats.
- Security monitoring: Stream Activity events into a SIEM or dashboard for real-time detection.
When you request a list of users, the unified API maps the provider's bespoke fields into this clean, predictable schema. Here is an example of how a normalized user record looks when extracted through a unified API, regardless of whether the underlying system is HubSpot, Zendesk, or GitHub:
{
"id": "usr_01H9X...",
"remote_id": "U12345678",
"name": "Jane Doe",
"email": "jane.doe@company.com",
"status": "active",
"department": "Engineering",
"role_assignments": [
{
"role_id": "rol_01H9Y...",
"remote_role_id": "admin_role_99",
"name": "System Administrator"
}
],
"last_login_at": "2026-10-14T08:30:00Z"
}One architectural nuance worth calling out: rigid unified schemas break the moment a customer asks about a custom role in Salesforce or a custom field on a Notion user. A well-designed unified directory layer should let you pass through provider-native fields alongside the normalized ones, and should let per-customer field mappings override defaults without a code change. Truto maintains the mapping configurations centrally, shielding your application from breaking changes when a vendor updates their API version.
Handling Rate Limits and Pagination Across 100+ APIs
Extracting user directories for an enterprise customer involves pulling thousands of records. This triggers the two most painful aspects of bulk API integration: pagination and rate limiting. Every SaaS vendor implements these differently.
Pagination alone spans at least four common patterns:
- Cursor-based (Slack, Notion): An opaque token in the response; pass it back in the next request.
- Page number (Jira, older APIs):
?page=2&per_page=100. - Offset/limit (Salesforce SOQL, many SQL-backed APIs):
?offset=1000&limit=200. - Link header (GitHub, GitLab): RFC 5988
Link: <...>; rel="next"header.
A unified directory API abstracts all of this behind a single response envelope—typically { data: [...], next_cursor: "..." }—and lets the caller iterate without caring what is happening upstream.
Rate limits, however, require a more nuanced architectural approach. When executing bulk data extraction, you will inevitably hit upstream API limits. Many integration platforms attempt to hide rate limits by silently queueing requests. This creates unpredictable latency and makes it impossible for your application to know the true state of a sync.
Truto takes a radically transparent approach:
- Upstream HTTP 429 responses are passed through directly to the caller. Truto does not silently retry, throttle, or apply opaque backoff.
- Upstream rate-limit signals are normalized into standardized response headers per the IETF RateLimit header fields spec:
ratelimit-limit,ratelimit-remaining, andratelimit-reset. - Retry, backoff, and queuing decisions stay in the caller's control.
This matters because different customers want different behavior. A GRC platform running a nightly access-review pull can tolerate long backoffs. An interactive access-request UI cannot. By exposing these headers, you maintain complete control over your application's logic. You can pause your extraction worker precisely until the ratelimit-reset timestamp, ensuring you never waste compute cycles.
Here is a sane consumer pattern in TypeScript demonstrating how to handle pagination and proactive rate limiting:
async function fetchAllUsers(accountId: string) {
const users: User[] = [];
let cursor: string | null = null;
while (true) {
const res = await truto.get('/unified/directory/users', {
params: { integrated_account_id: accountId, next_cursor: cursor, limit: 200 },
});
if (res.status === 429) {
// Read the normalized IETF rate limit headers
const reset = Number(res.headers['ratelimit-reset'] ?? 5);
console.log(`Rate limit hit. Sleeping for ${reset} seconds.`);
await sleep(reset * 1000);
continue;
}
users.push(...res.data.data);
cursor = res.data.next_cursor;
if (!cursor) break;
// Proactive throttle if remaining budget is low
const remaining = Number(res.headers['ratelimit-remaining'] ?? 100);
if (remaining < 10) await sleep(1000);
}
return users;
}Or in Python, handling the same standardized headers:
import time
import requests
def fetch_users_with_backoff(url, headers):
while True:
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
if response.status_code == 429:
reset_time = int(response.headers.get('ratelimit-reset', 0))
current_time = int(time.time())
sleep_duration = max(reset_time - current_time, 1)
print(f"Rate limit hit. Sleeping for {sleep_duration} seconds.")
time.sleep(sleep_duration)
continue
response.raise_for_status()Building Automated Access Reviews With Zero Integration Code
Once users, groups, roles, and assignments come back in a consistent shape, the access-review feature that lives on top becomes almost trivial to build. Identity sprawl is not a problem you can solve by throwing more engineers at bespoke API documentation. B2B buyers mandate automated user access reviews, and your platform needs a scalable way to deliver them.
sequenceDiagram participant CS as "Your App (Cron)" participant UD as "Unified Directory API" participant Providers as "100+ SaaS APIs" CS->>UD: GET /users (per integrated_account) UD->>Providers: Provider-specific calls (parallel) Providers-->>UD: Native payloads UD-->>CS: Normalized users + roles + last_active_at CS->>CS: Flag dormant, over-privileged, orphaned accounts CS->>CS: Emit review tasks to reviewer inboxes
The pseudo-code to drive this review process is boring in the best way possible:
for (const account of customer.integratedAccounts) {
const users = await fetchAllUsers(account.id);
const assignments = await fetchAllRoleAssignments(account.id);
const dormant = users.filter(u =>
daysSince(u.last_active_at) > 90 && u.is_active,
);
const admins = assignments.filter(a => a.role.name.match(/admin|owner/i));
await createReviewTasks({ dormant, admins, account });
}No per-provider branches. No Slack-specific pagination handler sitting next to a GitHub-specific rate-limit handler. Adding a new connector—like a niche vertical tool a customer asked for on Monday—is a configuration change, not a sprint.
By adopting a Unified Directory API, you shift the burden of integration maintenance off your engineering team. You bypass the massive blind spots left by SCIM and central IdPs. That is the actual promise of a unified directory API: your team ships the feature, not the integrations.
If you want to see the code-level walk-through, our end-to-end developer tutorial for pulling user lists shows the request-by-request flow, and our companion guide on how to pull user lists with a unified directory API covers the architectural tradeoffs in more depth.
Before you commit to any provider, ask two questions: (1) Does adding a new connector require a code deploy on their side, or is it configuration? (2) Do they pass upstream rate limits through with standardized headers, or do they hide them behind opaque retries? The answers determine whether you are buying a scalable abstraction or a black box.
Next Steps
If your team is staring at a list of 100+ SaaS apps and a multi-year roadmap, the architectural shift is straightforward: stop writing integration clients, start consuming a unified directory API. The math on build-vs-buy stops being close once you factor in ongoing maintenance, vendor API drift, and the security cost of shipping late.
A few concrete moves to get started:
- Audit your stack: Determine which apps in your customers' stacks actually matter for your use case (access reviews, license optimization, security monitoring). You probably need 40-60 for real coverage, not 500.
- Define your unified schema first: Draft the entities your feature actually needs—Users, Groups, Roles, RoleAssignments, Licenses, Activities—before evaluating any vendor or writing any code. This becomes the contract your app consumes.
- Prototype across diverse APIs: Prototype against three providers with wildly different shapes (e.g., Slack, Salesforce, and something niche). If the same code works for all three, the architecture is real.
Your roadmap should focus on building superior compliance workflows, intelligent access review UIs, and deep security analytics. It should not be bottlenecked by writing a custom Jira connector.
FAQ
- Why isn't integrating with Okta or SCIM enough to pull user lists from all SaaS apps?
- SCIM coverage is inconsistent, frequently misses shadow IT applications, and only reports on provisioning events rather than the current state of granular permissions and manual admin grants inside the application.
- How do unified directory APIs handle rate limits across 100+ different SaaS APIs?
- Truto passes upstream HTTP 429 errors directly through to the caller and normalizes rate-limit info into standardized IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset), leaving custom retry and backoff decisions in your control.
- What entities should a unified directory API model for access reviews?
- At minimum: Users, Groups, Roles, RoleAssignments, Licenses, Utilization signals, and Activity events. Together these cover automated onboarding, RBAC enforcement, license optimization, and security monitoring use cases.
- Can I add a new SaaS connector without deploying code?
- Yes. With a declarative architecture, adding a new connector is a configuration operation defining endpoints, auth type, pagination style, and field mappings in JSON. No integration-specific code lives in the runtime, requiring zero code deploys.