Developer Tutorial: Pulling User Lists End-to-End From Any SaaS API
A developer tutorial for pulling user lists and building AI ticket auto-responders across Zendesk, Jira, and 100+ SaaS APIs using a unified API architecture.
If your engineering team is tasked with pulling user lists, roles, and access levels from every SaaS application your customers use, you are looking at a massive architectural hurdle. B2B software buyers expect your platform to automatically discover who has access to their tools. If you intend to publish a developer tutorial or build this internally, pulling user lists end-to-end requires moving beyond one-off API scripts and adopting a highly scalable, unified architecture.
Building this yourself, one integration at a time, is a trap. You are sitting in a sprint planning meeting. Your product manager drops a seemingly simple requirement: "Our customers need to pull a list of users from all their SaaS apps so we can run automated access reviews."
You do the math. According to ElectroIQ's 2024 data, the average company uses 130 different SaaS applications, and 75% of employees are expected to acquire, modify, or create technology without IT's oversight by 2027. Your engineering team can realistically build, test, and maintain a few production-grade API integrations per quarter. 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.
Identity sprawl is directly breaking product roadmaps and introducing massive security risks. 58% of organizations struggle to enforce proper privilege management, and 51% lack the ability to properly offboard dormant identities and accounts, according to the 2025-2026 State of SaaS Security report from Valence Security and the Cloud Security Alliance. Tripwire's 2025 State of SaaS Security report notes that 41% of breaches are caused by overprivileged accounts. Furthermore, 31% of companies have experienced former employees accessing assets stored in SaaS applications after departure (Security Magazine). Every orphaned account is a breach vector waiting to be exploited.
This guide provides a practical, step-by-step technical blueprint for programmatically extracting user directories from disparate SaaS APIs without writing bespoke integration code for each vendor.
Why You Need a Unified Approach to Pulling User Lists
The core problem is straightforward: user data is scattered across hundreds of SaaS platforms, each with its own API, authentication scheme, data model, and pagination strategy.
Here is what the math actually looks like for a mid-stage B2B SaaS company building an access review or directory sync feature:
| Factor | Reality |
|---|---|
| Avg. SaaS apps per customer | 100+ |
| Engineering time per connector | 2-4 weeks (auth + mapping + pagination + testing) |
| Ongoing maintenance per connector | ~20% of initial build effort annually |
| API documentation quality | Wildly inconsistent |
| Breaking changes per year | 2-5 per vendor on average |
Multiply that across even 50 integrations and you have consumed your entire engineering roadmap for years. This is why teams adopt a Unified Directory API approach - a single interface that normalizes user data from many providers into a common schema.
The architectural pattern looks like this:
flowchart LR
A["Your Application"] -->|Single API Call| B["Unified Directory API"]
B --> C["Salesforce"]
B --> D["Zendesk"]
B --> E["Jira"]
B --> F["Slack"]
B --> G["100+ more..."]Instead of writing 100 integrations, you write one. The normalization layer handles the ugly differences between providers.
The Limitations of SCIM and Standard IdP Integrations
The standard engineering reflex is to integrate with Okta, Microsoft Entra ID, or Google Workspace and call it a day. If a customer wants user data, they should just push it via the System for Cross-domain Identity Management (SCIM) protocol.
This approach fails upon contact with reality. Connecting to major Identity Providers (IdPs) is table stakes, but it leaves a massive blind spot across the long tail of identity. If you have built SCIM integrations before, you already know the primary failure modes:
1. The SSO Tax limits coverage. Lots of applications do not support SCIM at all. For those that do, vendors often lock it behind expensive enterprise tiers. As noted by Zluri in their 2025 analysis, most SaaS vendors bundle SCIM with SSO in their enterprise pricing tiers, meaning buyers must upgrade to plans that cost two to four times the base price just to enable it.
Specific examples of this SCIM gatekeeping include:
- Salesforce supports SCIM 2.0 provisioning, but only on Enterprise ($175/user/month) and Unlimited ($350/user/month) editions.
- ServiceNow requires Enterprise plans ($50-75/user/month at scale).
- Atlassian gates SCIM behind an Atlassian Guard subscription that stacks on top of your existing Jira and Confluence licenses.
Your mid-market customers will simply not have SCIM enabled for half their stack.
2. SCIM is a push protocol, not a pull protocol. As covered in our guide to directory integrations, SCIM automates the exchange of user identity data from the client to the service provider. It was designed to automate the provisioning lifecycle - pushing user creates, updates, and deactivates from an IdP to a service provider.
It was not designed to answer the question: "Who has access to this app right now, and what permissions do they have?" It is not designed to audit existing access, discover shadow IT, or pull granular, application-specific permissions that were assigned manually by an administrator inside the app.
If a department head buys a specialized design tool on a corporate credit card and manually invites five team members, Okta has no idea those accounts exist. To get an accurate picture of identity, you must query the application's API directly.
Step 1: Authenticating Across Multiple SaaS Platforms
The first wall you hit when pulling user data from multiple SaaS APIs is authentication. Every vendor has a different scheme, and even vendors using the "same" standard (OAuth 2.0) implement it differently.
Here is what you are actually dealing with across a typical integration portfolio:
- OAuth 2.0 Authorization Code Flow - Salesforce, HubSpot, Slack, Microsoft 365
- OAuth 2.0 Client Credentials - ServiceNow, some Atlassian endpoints
- API Key in Header - Zendesk, many smaller SaaS tools
- Bearer Token with Custom Headers - Vendors that require extra tenant identifiers or custom auth expressions alongside a token
- Session-Based Auth - Legacy apps that require a login step to obtain a session cookie
Managing OAuth state is notoriously difficult. Access tokens expire. Refresh tokens get revoked. If you build this in-house, your database will quickly fill up with token refresh logic, encrypted credential storage, and state management tables.
A well-designed abstraction layer stores the auth configuration declaratively - specifying the OAuth URLs, scopes, and token endpoints per provider - and then executes the correct flow at runtime without integration-specific code.
Using a unified API platform like Truto abstracts this away entirely. Truto handles the entire OAuth token lifecycle securely. The platform schedules work ahead of token expiry, automatically refreshing OAuth tokens shortly before they expire without manual intervention.
Here is a visualization of how a unified platform handles the OAuth flow while keeping your application logic clean:
sequenceDiagram
participant User
participant Your App
participant Truto
participant SaaS API (e.g., Salesforce)
User->>Your App: Clicks "Connect Salesforce"
Your App->>Truto: Request Link Token<br>(Tenant ID)
Truto-->>Your App: Returns Secure UI URL
Your App->>User: Redirect to Auth UI
User->>SaaS API: Approves OAuth Scopes
SaaS API-->>Truto: Returns Auth Code
Truto->>SaaS API: Exchanges Code for Tokens
Truto-->>Your App: Webhook: Connection Successful
Note over Truto,SaaS API: Truto autonomously refreshes<br>tokens before they expireWhen you need to pull users, you do not pass tokens. You simply pass the Truto Tenant ID (or Account ID), and the platform injects the correct, unexpired credentials into the outbound request.
Here is what a typical authenticated request looks like from the caller's perspective when using a unified API:
curl -X GET "https://api.truto.one/unified/directory/users" \
-H "Authorization: Bearer YOUR_TRUTO_TOKEN" \
-H "X-Integrated-Account-ID: customer_salesforce_account_id"One request. One auth header. The unified layer resolves which provider, which auth scheme, and which credentials to use for that specific customer's connected account.
Step 2: Mapping Disparate APIs to a Unified Directory Schema
Once authenticated, you face the data modeling problem. Every SaaS application structures its user object differently.
A raw Salesforce user response looks like this:
{
"Id": "0055e000001VbO2AAK",
"Username": "jdoe@example.com",
"LastName": "Doe",
"FirstName": "John",
"IsActive": true,
"ProfileId": "00e5e000000Qk12AAC"
}A raw Zendesk user response looks like this:
{
"id": 9873843,
"name": "John Doe",
"email": "jdoe@example.com",
"role": "agent",
"suspended": false,
"organization_id": 443212
}Jira returns displayName, emailAddress, and groups. Slack gives you real_name, profile.email, and is_admin.
If you write custom code to parse these, you are building technical debt. Your downstream code needs a custom parser per provider. Instead, you need a declarative mapping layer. By shipping connectors as data-only operations, you can normalize these payloads into a single, predictable Unified Directory schema without writing integration-specific code.
Using JSONata (a lightweight query and transformation language for JSON), the platform maps the disparate fields into a common model:
{
"id": "usr_abc123",
"remote_id": "0055e000001VbO2AAK",
"first_name": "John",
"last_name": "Doe",
"email": "jdoe@example.com",
"role": "00e5e000000Qk12AAC",
"status": "active",
"department": "Engineering",
"groups": [
{ "id": "grp_1", "name": "Platform Team" }
],
"created_at": "2024-03-15T09:00:00Z",
"raw": { /* original provider response */ }
}The raw field is important. Any normalization layer that throws away the original response is hiding data you will eventually need. Custom fields, provider-specific permission objects, license tier information - your compliance team will ask for it, and it should be there.
flowchart TB
subgraph Providers
SF["Salesforce<br>Username, Profile.Name,<br>UserRole.Name"]
ZD["Zendesk<br>email, role,<br>organization_id"]
JR["Jira<br>displayName,<br>emailAddress, groups"]
end
subgraph Mapping["Declarative Mapping Layer"]
M["Field-level<br>transformation expressions<br>(JSONata)"]
end
subgraph Output["Unified Schema"]
U["first_name, last_name,<br>email, role, status,<br>department, groups"]
end
SF --> M
ZD --> M
JR --> M
M --> UWhen evaluating unified APIs, always check whether the mapping layer is extensible. Can you add custom field mappings per customer? Enterprise Salesforce orgs routinely use custom permission sets and profile structures that will not match any static schema.
This means your application only ever writes one code path. You request /unified/users from the platform, and you receive the exact same JSON structure whether the underlying tool is Jira, Salesforce, Zendesk, or GitHub.
Step 3: Handling Pagination and Rate Limits Gracefully
Extracting a directory of 10,000 users will immediately trigger API rate limits and require strict pagination handling.
Pagination is never consistent across providers. You will encounter:
- Cursor-based pagination (Slack, Stripe) - uses an opaque token to fetch the next page
- Offset-based pagination (Salesforce SOQL, many REST APIs) - uses
offsetandlimitparameters - Link header pagination (GitHub, some HRIS systems) - next page URL in the response headers
- Page number pagination (older APIs) - simple
page=Nparameters
A unified platform normalizes pagination. You pass a standard cursor parameter to the unified endpoint, and the platform translates that into the specific pagination style required by the upstream provider. You request the first page, get a next_cursor in the response, and pass it back for the next page.
Rate limits require a different approach. Every SaaS vendor enforces different limits (Salesforce gives you a daily API call budget, Slack uses per-method per-workspace limits, Zendesk uses per-minute windows), and exceeding them means dropped requests or temporary bans.
Architectural Note on Rate Limits Truto does not retry, throttle, or apply backoff on rate limit errors. When an upstream API returns an HTTP 429 (Too Many Requests), Truto passes that error directly to the caller.
Absorbing rate limits sounds helpful, but it creates unpredictable latency spikes and masks underlying architectural issues in your sync engine. Silent retries inside a middleware layer make it impossible for you to implement intelligent request scheduling.
Instead, Truto normalizes upstream rate limit information into standardized headers per the IETF specification:
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.
Your application is responsible for reading these headers and implement retry and exponential backoff logic.
Here is a practical example of how a senior engineer should handle user extraction with standard backoff logic in Node.js:
async function extractUsersWithBackoff(tenantId, cursor = null) {
let url = `https://api.truto.one/unified/directory/users`;
if (cursor) url += `?cursor=${cursor}`;
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${process.env.TRUTO_API_KEY}`,
'x-integrated-account-id': tenantId
}
});
if (response.status === 429) {
// Read the standardized IETF headers
const resetTime = response.headers.get('ratelimit-reset');
const waitSeconds = resetTime ?
Math.max(1, resetTime - Math.floor(Date.now() / 1000)) : 5;
console.log(`Rate limited. Waiting ${waitSeconds} seconds.`);
await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
// Retry the exact same request
return extractUsersWithBackoff(tenantId, cursor);
}
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const data = await response.json();
return data;
} catch (error) {
console.error('Extraction failed:', error);
throw error;
}
}If your stack is Python, here is a minimal retry handler using the same normalized headers:
import time
import requests
def fetch_users_with_retry(url, headers, max_retries=3):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
reset_time = int(response.headers.get('ratelimit-reset', 5))
# Use reset time if available, otherwise exponential backoff
backoff = min(reset_time, 2 ** attempt * 2)
print(f"Rate limited. Retrying in {backoff}s...")
time.sleep(backoff)
continue
response.raise_for_status()
return response.json()
raise Exception("Max retries exceeded")This pattern ensures that your extraction pipeline respects the upstream provider's infrastructure while maintaining high throughput.
Step 4: Automating the Extraction Pipeline
With authentication, data mapping, and rate limits handled, the final step is orchestrating the extraction pipeline. User directories are not static. Employees join, leave, and change roles daily. Pulling user lists once is a demo. Pulling them continuously on a schedule, reliably, is a production feature.
Here is the extraction pipeline architecture you should target:
sequenceDiagram
participant Scheduler as Your Scheduler
participant App as Your App
participant API as Unified Directory API
participant SaaS as Upstream SaaS
Scheduler->>App: Trigger extraction (cron / webhook)
App->>API: GET /unified/directory/users
API->>SaaS: Fetch users (with auth, pagination)
SaaS-->>API: User data (page 1)
API-->>App: Normalized users + next_cursor
loop Until all pages fetched
App->>API: GET /unified/directory/users?cursor=...
API->>SaaS: Fetch next page
SaaS-->>API: User data (page N)
API-->>App: Normalized users + next_cursor
end
App->>App: Diff against last snapshot
App->>App: Flag new, removed, or changed users
App->>App: Trigger alerts or workflowsThe key elements of a production pipeline:
- Scheduled triggers: Run extraction on a cadence that matches your compliance requirements (e.g., daily for SOC 2 access reviews, hourly for near-real-time offboarding alerts). A distributed task queue (like Celery, Temporal, or BullMQ) triggers a sync job for a specific tenant.
- Incremental syncing: Use
updated_afterfilters when supported by the upstream API to avoid re-fetching the entire user list on every run. - Extraction Loop: The worker calls the unified endpoint, following the normalized
next_cursoruntil it returns null. It respects theratelimit-resetheaders if an HTTP 429 is encountered. - Snapshot diffing: The worker compares the extracted unified user list against the state stored in your database. It flags new accounts, suspended accounts, and role changes.
- Alerting on anomalies: If shadow IT is detected (e.g., an active user in Zendesk who is suspended in Okta), the system generates an alert for the security team. Orphaned accounts and privilege escalations should trigger immediate notifications.
Do not store raw user data longer than you need it. If you are building access review features, your customers' security teams will ask about your data retention policies. Design for minimal data retention from the start - it is far easier than retrofitting it later.
For the extraction itself, a practical implementation loops through all of a customer's connected accounts and pulls the user list from each:
import requests
def extract_all_users(api_base, token, integrated_accounts):
all_users = []
for account in integrated_accounts:
cursor = None
while True:
params = {"cursor": cursor} if cursor else {}
resp = requests.get(
f"{api_base}/unified/directory/users",
headers={
"Authorization": f"Bearer {token}",
"X-Integrated-Account-ID": account["id"]
},
params=params
)
# Handle rate limits (see retry handler above)
data = resp.json()
all_users.extend(data["results"])
cursor = data.get("next_cursor")
if not cursor:
break
return all_usersThis script pulls normalized user objects from Salesforce, Zendesk, Jira, Slack, and whatever other accounts the customer has connected - all through the same code path, the same schema, and the same pagination interface.
Handling Complex Upstream APIs with the Proxy API
Occasionally, you will encounter modern SaaS platforms (like Linear or modern GitHub endpoints) that expose their data exclusively via GraphQL. Integrating a GraphQL API into a standard RESTful extraction pipeline usually requires writing custom query builders and managing completely different operational logic.
A top-tier unified API platform solves this by providing a Proxy API that exposes GraphQL-backed integrations as RESTful CRUD resources. The platform handles the placeholder-driven request building and response extraction behind the scenes. Your extraction worker simply makes a standard GET /proxy/linear/users request, and the platform translates that into the necessary GraphQL query, returning a flat JSON array that fits perfectly into your existing pipeline.
Beyond User Lists: Building an AI Ticket Auto-Responder for Zendesk and Jira
The unified API patterns covered above - auth abstraction, declarative schema mapping, normalized pagination, and standardized rate limits - are not limited to pulling user directories. The same architecture applies to ticketing operations. Truto's Unified Ticketing API provides a single interface to read tickets, post comments, and update statuses across Zendesk, Jira, ServiceNow, Linear, and dozens more.
This means you can build an AI agent that auto-responds to support tickets across every platform your customers use - without writing provider-specific integration code. Here is a complete, runnable playbook for that pipeline.
Architecture: Webhook → Queue → Consumer → Write-Back
sequenceDiagram
participant ZJ as Zendesk / Jira
participant WH as Webhook Handler
participant Q as Message Queue
participant W as Consumer Worker
participant API as Unified Ticketing API
participant VDB as Vector DB
participant LLM as LLM
ZJ->>WH: POST /webhooks/tickets
WH-->>ZJ: 200 OK (immediate)
WH->>Q: Enqueue event
Q->>W: Dequeue event
W->>API: GET /unified/ticketing/tickets/{id}
API-->>W: Ticket content
W->>VDB: Query with ticket text
VDB-->>W: Relevant KB articles
W->>LLM: Prompt + context
LLM-->>W: Generated response
W->>API: POST /unified/ticketing/comments
W->>API: PATCH /unified/ticketing/tickets/{id}
API->>ZJ: Provider-specific write-backThe pipeline has four stages:
- Webhook ingestion. Your endpoint receives a ticket-created or customer-replied event, returns HTTP 200 immediately, and pushes the raw event onto a queue.
- Queue. A message broker (Redis, RabbitMQ, or any durable queue) decouples ingestion from processing. This absorbs traffic spikes and prevents dropped events while your LLM calls take 2-10 seconds each.
- Consumer. A worker pulls events off the queue, fetches the full ticket via the unified ticketing API, queries a vector database for relevant knowledge base articles, assembles a prompt, and calls an LLM.
- Write-back. The consumer posts the LLM's response as a comment on the ticket and transitions the status to "Pending Customer Response" - all through the same unified API, whether the ticket lives in Zendesk or Jira.
Webhook Handler: Immediate ACK and Enqueue
Zendesk webhook requests have a 12-second timeout, and webhooks that time out are retried up to five times. Jira webhooks have a similar constraint. If your handler tries to run vector search and LLM inference inline, you will miss the timeout, the platform will retry, and you will end up with duplicate AI responses on your customer's tickets.
The rule is simple: ACK first, process later.
Node.js (Express + BullMQ):
import express from 'express';
import { Queue } from 'bullmq';
import crypto from 'crypto';
const app = express();
app.use(express.json());
const ticketQueue = new Queue('ticket-events', {
connection: { host: process.env.REDIS_HOST || 'localhost', port: 6379 }
});
app.post('/webhooks/tickets', async (req, res) => {
// ACK immediately - do NOT process inline
res.status(200).json({ received: true });
const event = req.body;
const eventId = event.id || crypto.randomUUID();
await ticketQueue.add('process-ticket', {
eventId,
ticketId: event.data?.ticket_id || event.data?.issue_id,
integratedAccountId: req.headers['x-integrated-account-id'],
payload: event
}, {
jobId: eventId, // BullMQ deduplicates by jobId
attempts: 3,
backoff: { type: 'exponential', delay: 2000 }
});
});
app.listen(3000, () => console.log('Webhook handler listening on :3000'));Python (Flask + Celery):
import uuid
from flask import Flask, request, jsonify
from celery import Celery
app = Flask(__name__)
celery_app = Celery('tasks', broker='redis://localhost:6379/0')
@app.route('/webhooks/tickets', methods=['POST'])
def handle_ticket_webhook():
event = request.get_json()
event_id = event.get('id', str(uuid.uuid4()))
ticket_id = (event.get('data', {}).get('ticket_id')
or event.get('data', {}).get('issue_id'))
process_ticket_event.apply_async(
args=[event_id, ticket_id, event],
task_id=event_id # Celery deduplicates by task_id
)
# Return 200 before doing any real work
return jsonify({"received": True}), 200Both handlers share the same pattern: parse the event, extract the ticket ID, push it onto the queue with the event ID as the deduplication key, and return 200 before doing any processing.
Queue Consumer: Vector Search, Prompt Assembly, LLM Call
The consumer is where the AI logic lives. It pulls an event from the queue, fetches the full ticket content through the unified API, searches your knowledge base for relevant articles, and generates a response.
Node.js (BullMQ Worker):
import { Worker } from 'bullmq';
const TRUTO_API = 'https://api.truto.one';
const TRUTO_TOKEN = process.env.TRUTO_API_KEY;
async function fetchTicket(ticketId, accountId) {
const res = await fetch(
`${TRUTO_API}/unified/ticketing/tickets/${ticketId}`,
{
headers: {
'Authorization': `Bearer ${TRUTO_TOKEN}`,
'X-Integrated-Account-ID': accountId
}
}
);
if (!res.ok) throw new Error(`Ticket fetch failed: ${res.status}`);
return res.json();
}
async function searchKnowledgeBase(query) {
// Replace with your vector DB client (Pinecone, Qdrant, Weaviate, pgvector)
const res = await fetch(
'http://localhost:6333/collections/kb/points/search',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vector: await embedText(query),
limit: 5,
with_payload: true
})
}
);
return res.json();
}
async function generateResponse(ticket, kbResults) {
const context = kbResults.result
.map(r => r.payload.text)
.join('\n---\n');
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4o',
temperature: 0.3,
messages: [
{
role: 'system',
content: [
'You are a support agent. Answer the customer\'s question',
'using ONLY the provided knowledge base articles. If the',
'articles do not contain a clear answer, say you are',
'escalating to a human agent. Be concise and direct.'
].join(' ')
},
{
role: 'user',
content: [
`Customer ticket:`,
`Subject: ${ticket.subject}`,
`Description: ${ticket.description}`,
``,
`Knowledge base articles:`,
context
].join('\n')
}
]
})
});
const data = await res.json();
return data.choices[0].message.content;
}
// postComment and updateTicketStatus defined in the write-back section below
const worker = new Worker('ticket-events', async (job) => {
const { ticketId, integratedAccountId, eventId } = job.data;
// 1. Fetch full ticket from unified API
const ticket = await fetchTicket(ticketId, integratedAccountId);
// 2. Search knowledge base
const kbResults = await searchKnowledgeBase(
`${ticket.subject} ${ticket.description}`
);
// 3. Generate LLM response
const aiResponse = await generateResponse(ticket, kbResults);
// 4. Post comment via unified ticketing API
await postComment(ticketId, integratedAccountId, aiResponse);
// 5. Transition status to "Pending Customer Response"
await updateTicketStatus(ticketId, integratedAccountId, 'pending');
return { ticketId, responded: true };
}, {
connection: { host: process.env.REDIS_HOST || 'localhost', port: 6379 },
concurrency: 5
});Python (Celery Worker):
import os
import requests
TRUTO_API = 'https://api.truto.one'
TRUTO_TOKEN = os.environ['TRUTO_API_KEY']
OPENAI_KEY = os.environ['OPENAI_API_KEY']
def truto_headers(account_id):
return {
'Authorization': f'Bearer {TRUTO_TOKEN}',
'X-Integrated-Account-ID': account_id,
'Content-Type': 'application/json'
}
@celery_app.task(bind=True, max_retries=3)
def process_ticket_event(self, event_id, ticket_id, event):
account_id = event.get('integrated_account_id')
# 1. Fetch full ticket from unified API
ticket = requests.get(
f'{TRUTO_API}/unified/ticketing/tickets/{ticket_id}',
headers=truto_headers(account_id)
).json()
# 2. Vector search for relevant KB articles
kb_results = search_knowledge_base(
f"{ticket['subject']} {ticket['description']}"
)
context = '\n---\n'.join([r['payload']['text'] for r in kb_results])
# 3. Call LLM
llm_resp = requests.post(
'https://api.openai.com/v1/chat/completions',
headers={
'Authorization': f'Bearer {OPENAI_KEY}',
'Content-Type': 'application/json'
},
json={
'model': 'gpt-4o',
'temperature': 0.3,
'messages': [
{'role': 'system', 'content': (
'You are a support agent. Answer using ONLY the provided '
'knowledge base articles. If no clear answer exists, '
'say you are escalating to a human agent.'
)},
{'role': 'user', 'content': (
f"Customer ticket:\nSubject: {ticket['subject']}\n"
f"Description: {ticket['description']}\n\n"
f"Knowledge base articles:\n{context}"
)}
]
}
).json()
ai_reply = llm_resp['choices'][0]['message']['content']
# 4. Post comment + update status (functions defined below)
post_comment(ticket_id, account_id, ai_reply)
update_ticket_status(ticket_id, account_id, 'pending')Always include a confidence gate. Do not auto-post every LLM response blindly. Check the similarity score from your vector search - if the best match is below a threshold (e.g., 0.75 cosine similarity), skip the auto-response and route the ticket to a human agent. Hallucinated answers on customer tickets erode trust faster than slow response times.
Writing Back: Comments and Status Changes via the Unified Ticketing API
This is where the unified approach pays off most. Zendesk and Jira have completely different APIs for posting comments and changing ticket status. With the unified ticketing API, your write-back code is identical regardless of which platform the ticket lives on.
Posting a Comment
curl -X POST "https://api.truto.one/unified/ticketing/comments" \
-H "Authorization: Bearer YOUR_TRUTO_TOKEN" \
-H "X-Integrated-Account-ID: customer_zendesk_account_id" \
-H "Content-Type: application/json" \
-d '{
"ticket_id": "ticket_abc123",
"body": "Based on our knowledge base, here is the solution...",
"is_private": false
}'Under the hood, this single call translates to very different provider-specific operations:
| Provider | What the Unified API Does Under the Hood |
|---|---|
| Zendesk | PUT /api/v2/tickets/{id} with a nested comment object containing body and public fields |
| Jira | POST /rest/api/3/issue/{key}/comment with an Atlassian Document Format body |
| ServiceNow | Creates a journal field record linked to the incident |
| Linear | Executes a commentCreate GraphQL mutation |
You do not need to know any of this. Your code posts to one endpoint.
Node.js write-back functions:
async function postComment(ticketId, accountId, body) {
const res = await fetch(`${TRUTO_API}/unified/ticketing/comments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TRUTO_TOKEN}`,
'X-Integrated-Account-ID': accountId,
'Content-Type': 'application/json'
},
body: JSON.stringify({
ticket_id: ticketId,
body: body,
is_private: false
})
});
if (!res.ok) throw new Error(`Comment post failed: ${res.status}`);
return res.json();
}
async function updateTicketStatus(ticketId, accountId, status) {
const res = await fetch(
`${TRUTO_API}/unified/ticketing/tickets/${ticketId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${TRUTO_TOKEN}`,
'X-Integrated-Account-ID': accountId,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status })
}
);
if (!res.ok) throw new Error(`Status update failed: ${res.status}`);
return res.json();
}Python write-back functions:
def post_comment(ticket_id, account_id, body):
resp = requests.post(
f'{TRUTO_API}/unified/ticketing/comments',
headers=truto_headers(account_id),
json={
'ticket_id': ticket_id,
'body': body,
'is_private': False
}
)
resp.raise_for_status()
return resp.json()
def update_ticket_status(ticket_id, account_id, status):
resp = requests.patch(
f'{TRUTO_API}/unified/ticketing/tickets/{ticket_id}',
headers=truto_headers(account_id),
json={'status': status}
)
resp.raise_for_status()
return resp.json()Jira Transitions: The Hidden Complexity
Jira does not let you set a status directly. The GET /rest/api/2/issue/{issueIdOrKey}/transitions endpoint returns all available transitions for an issue in its current status, so you must first query for the right transition ID and then execute it. This two-step dance is a common source of bugs for teams building Jira integrations from scratch.
Here is what you would normally have to do with the Jira REST API directly:
# Step 1: Discover available transitions for this issue
curl -X GET "https://your-instance.atlassian.net/rest/api/3/issue/PROJ-123/transitions" \
-H "Authorization: Basic BASE64_CREDENTIALS"
# Response:
# { "transitions": [
# { "id": "31", "name": "In Progress", "to": { "name": "In Progress" } },
# { "id": "41", "name": "Done", "to": { "name": "Done" } }
# ]}
# Step 2: Execute the transition by ID
curl -X POST "https://your-instance.atlassian.net/rest/api/3/issue/PROJ-123/transitions" \
-H "Authorization: Basic BASE64_CREDENTIALS" \
-H "Content-Type: application/json" \
-d '{ "transition": { "id": "31" } }'With the unified ticketing API, you skip both steps. You PATCH the ticket with { "status": "in_progress" }, and the platform resolves the correct transition behind the scenes. If you need to execute a provider-specific transition that does not map to the unified status enum, the Proxy API gives you direct access to the underlying Jira endpoint while still handling authentication.
Idempotency and Deduplication
Webhook providers will send duplicate events. Zendesk makes a best effort to deliver webhook actions a single time but cannot guarantee it - a webhook can be invoked by the same action multiple times. Jira retries on non-2xx responses. Your queue might redeliver after a worker crash. If your consumer is not idempotent, customers will receive duplicate AI responses on their tickets.
Three layers of defense:
1. Deduplicate at the webhook handler. Use the event ID (provided by most webhook payloads) as the queue job ID. Both BullMQ and Celery will silently drop jobs with duplicate IDs.
2. Deduplicate at the consumer. Before posting a comment, check whether you have already processed this event. A Redis SET NX with a TTL is the lightest-weight approach:
// Node.js - Redis-based idempotency lock
import Redis from 'ioredis';
const redis = new Redis();
async function acquireLock(eventId, ttlSeconds = 86400) {
// Returns 'OK' if the key was set, null if it already existed
const result = await redis.set(
`processed:${eventId}`, '1', 'EX', ttlSeconds, 'NX'
);
return result === 'OK';
}
// In your worker, before any processing:
const isNew = await acquireLock(job.data.eventId);
if (!isNew) {
console.log(`Event ${job.data.eventId} already processed. Skipping.`);
return;
}# Python - Redis-based idempotency lock
import redis
r = redis.Redis()
def acquire_lock(event_id, ttl=86400):
"""Returns True if this event has not been processed yet."""
return r.set(f'processed:{event_id}', '1', ex=ttl, nx=True)
# In your Celery task, before any processing:
if not acquire_lock(event_id):
print(f'Event {event_id} already processed. Skipping.')
return3. Make the write-back itself idempotent. If the consumer crashes after posting the comment but before marking the event as processed, a retry will post a second comment. To guard against this, include the event ID in the comment metadata (as an internal note or custom field) and check for its presence before posting.
Local Dev and Test Harness
You can run the full pipeline locally with Docker Compose and a simple webhook simulator.
docker-compose.yml:
version: "3.8"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
vector-db:
image: qdrant/qdrant:latest
ports:
- "6333:6333"Start infrastructure, then run your handler and worker in separate terminals:
# Terminal 1: Start Redis and vector DB
docker compose up -d
# Terminal 2: Start the webhook handler
TRUTO_API_KEY=your_key OPENAI_API_KEY=your_key node src/handler.js
# Terminal 3: Start the queue worker
TRUTO_API_KEY=your_key OPENAI_API_KEY=your_key node src/worker.jsSimulating a webhook event for testing:
curl -X POST http://localhost:3000/webhooks/tickets \
-H "Content-Type: application/json" \
-H "X-Integrated-Account-ID: test_account_001" \
-d '{
"id": "evt_test_001",
"type": "ticket.created",
"data": {
"ticket_id": "ticket_12345",
"subject": "Cannot reset my password",
"description": "I have tried the reset link three times and it keeps expiring."
}
}'Minimal test script (Node.js built-in test runner):
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('Webhook handler', () => {
it('returns 200 immediately', async () => {
const res = await fetch('http://localhost:3000/webhooks/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'evt_test_dedup_1',
type: 'ticket.created',
data: { ticket_id: 'tkt_999', subject: 'Test', description: 'Test' }
})
});
assert.strictEqual(res.status, 200);
});
it('deduplicates identical events', async () => {
const payload = JSON.stringify({
id: 'evt_test_dedup_2',
type: 'ticket.created',
data: { ticket_id: 'tkt_999', subject: 'Test', description: 'Test' }
});
const headers = { 'Content-Type': 'application/json' };
await fetch('http://localhost:3000/webhooks/tickets', {
method: 'POST', headers, body: payload
});
await fetch('http://localhost:3000/webhooks/tickets', {
method: 'POST', headers, body: payload
});
// Assert only one job exists in the queue for this event ID
// (inspect via BullMQ's getJob API or Redis CLI)
});
});For CI, run the Docker Compose stack in your pipeline, fire sample events, and assert that comments appear as expected. If you are using Truto's sandbox environment, you can validate the full round-trip against real (sandboxed) Zendesk and Jira instances without affecting production data.
What This Architecture Actually Buys You
Let's be direct about the trade-offs. Using a unified API for user list extraction is not free of downsides:
- You are adding a dependency. If the unified API goes down, your extraction pipeline stops. Evaluate uptime SLAs carefully.
- Edge cases exist. Some providers expose user data through non-standard endpoints (GraphQL-only APIs, SOAP services, custom report endpoints). Make sure your provider covers these through flexible proxy layers.
- Custom fields need custom mappings. A static unified schema will not capture your customer's custom Salesforce permission sets or Workday business process security groups. You need a layer that supports per-customer mapping overrides.
What you do get in return:
- Months of engineering time back. Instead of building 50+ auth flows, pagination handlers, and schema parsers, you build one integration against a unified API.
- Consistent security posture. Credential management, token refresh, and encryption are handled in one place instead of scattered across dozens of connector implementations.
- Faster time-to-coverage. Adding a new SaaS provider to your access review feature takes hours instead of sprints.
For teams building access review features, compliance dashboards, or automated offboarding workflows, this architecture is the difference between shipping in weeks and shipping in years.
Next Steps for Your Integration Roadmap
Pulling user lists end-to-end across a highly fragmented SaaS ecosystem requires discipline. Relying on SCIM will leave you blind to shadow IT. Building point-to-point API connectors will drain your engineering resources and leave you managing a fragile web of OAuth tokens and custom pagination loops.
The practical next steps are straightforward:
- Audit your coverage gap. List every SaaS app your customers need user data from. Check which ones support SCIM, which offer a REST API, and which have no programmatic access at all.
- Evaluate whether a unified API covers your providers. No abstraction layer covers everything. Identify the gap between what is available out of the box and what you would need to build yourself.
- Prototype the extraction pipeline. Start with 3-5 high-priority providers, validate the schema mapping meets your needs, and test rate limit handling under realistic load.
- Design for incremental sync from day one. Full user list pulls are fine for initial loads. Ongoing syncs need to be incremental or you will burn through API rate limits and waste compute.
The identity sprawl problem is getting worse, not better. 46% of organizations struggle to monitor non-human identities, and 56% are concerned with over-privileged API access. The companies that solve user visibility at scale will have a genuine competitive advantage - both as a product feature and as a security posture.
Stop writing bespoke API connectors. Standardize your data models, respect upstream rate limits, and focus your engineering cycles on the core value of your product.
FAQ
- Why is SCIM not enough for pulling user lists from SaaS apps?
- SCIM is a push-based provisioning protocol, not a pull-based audit protocol. It was designed to push user creates and updates from an IdP to a service provider, not to query existing access. It fails to capture shadow IT and manually created accounts, and is often gated behind expensive enterprise tiers.
- How do unified APIs handle different rate limits across SaaS platforms?
- A unified API normalizes upstream rate limit data into standardized IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset). The caller is responsible for reading these headers and implementing exponential backoff logic when receiving an HTTP 429 error.
- Do I need to write custom code to map disparate user endpoints?
- No. Modern unified APIs use declarative mapping (like JSONata) to transform diverse API responses into a single, predictable JSON object without requiring integration-specific code, while still preserving the raw provider payload.
- How do I handle different pagination types across multiple SaaS APIs?
- SaaS APIs use cursor-based, offset-based, link header, or page number pagination. A unified API normalizes these into a single cursor-based interface so your code uses one pagination pattern regardless of the upstream provider.
- How should I handle OAuth token expiration when extracting data?
- You should offload token management to a platform that autonomously handles the lifecycle. The platform schedules work ahead of token expiry to refresh tokens, allowing you to pass a static tenant ID instead of managing raw tokens and refresh logic in your database.