Track 02 · SaaS Connectivity
In this hack you will wire a new connector into the platform. You will build a cloud-hosted connector in TypeScript that aggregates accounts and entitlements from a live demo system, then run it in your own tenant — no virtual appliance, and no system of your own to stand up.
The system you integrate with is the SaaS Connectivity Demo, a disposable target application built for this track. One click gives you a private set of accounts and entitlements to aggregate.
How to get started
Work through the steps in order. You build one command at a time, and each one is testable on its own before you move to the next. If a step leaves you stuck, open the hint under it to unblock yourself and keep moving — using them costs you nothing.
1. Install the prerequisites
Node 18 or newer
node --version
If that prints anything below 18, grab a current release from nodejs.org.
SailPoint CLI
- macOS
- Windows
- Linux
brew tap sailpoint-oss/tap && brew install sailpoint-cli
Download sail_2.4.0_windows_amd64.msi from the
releases page, double click it, and follow
the installer. 64-bit Windows only.
Download the .deb or .rpm from the
releases page, then install it:
sudo apt install ./sail_2.4.0_linux_amd64.deb
sudo yum localinstall ./sail_2.4.0_linux_amd64.rpm
Confirm it landed and that the version is 2.4.0 or newer:
sail --version
If the version printed is below 2.4.0, upgrade before continuing — some commands used in this guide are not available in older releases. Re-run the install command for your platform above to get the latest version.
Personal access token
The CLI acts on your behalf against your tenant's APIs, so it needs credentials. A personal access token (PAT) is the quickest way to get a pair — no OAuth app to register.
- In your tenant, open Preferences → Personal Access Tokens, or go straight to
https://[tenant].identitynow.com/ui/d/user-preferences/personal-access-tokens. - Click New Token and give it a description, such as
hack-day-cli. - For the vendor integration, choose Other / No associated vendor integration.
- Leave the scopes list empty. An empty list means the token defaults to
sp:scopes:alland inherits your own access, which is what the connector commands in step 9 need — pick narrower scopes and those commands will fail with a403. - Click Create Token, then copy the Client ID and Client Secret. The secret is never shown again.
More detail: personal access tokens.
Postman (optional)
If you would rather click than type, every test in this guide has a Postman equivalent.
- Install Postman, or use the web version.
- Fork the SaaS Connectivity collection into your own workspace.
- Open the Connector Commands folder. Every request already POSTs to
http://localhost:3000, which is exactly wherenpm run devlistens, so the URLs need no editing. - The only thing you must change is the
configblock. The collection ships with a generic{"token": "apikey"}placeholder — replace it with thebaseUrlandapiKeyyour connector actually reads.
To poke at the demo API directly rather than at your connector, import its OpenAPI description as a second collection: Import → Link.
More detail: Postman collection.
Point the CLI at your tenant
In your terminal, run these three commands one at a time. The first creates a named environment
pointing at your tenant, the second stores the PAT you just created, and the third tells the CLI to
authenticate with that PAT. You will be prompted for your tenant URL
(https://[tenant].identitynow.com) and API URL (https://[tenant].api.identitynow.com), then for
the client ID and secret from above.
sail env create hackday
sail set pat
sail set auth pat
Check that it authenticates. In your terminal, run:
sail conn list
It should return without an error. An empty list is a pass — it just means you have not created a
connector yet. A 403 or a scopes error means the PAT was created with narrower scopes than
sp:scopes:all; delete it and create a new one with the scopes list left empty.
More detail: CLI install and configuration.
2. Get your demo system
Open the demo app, click Create API key, then Continue, then Seed data.
You now have 50 accounts and 8 entitlements that only your key can see. Copy the key somewhere safe — it is shown once.
Every code example on this page will update automatically.
Your key and all of its data are deleted 7 days after you create it. It is a training sandbox, so never put real data in it.
3. Scaffold the connector project
In your terminal, paste these commands one at a time. sail conn init generates a working starter
project in a new folder, cd moves you into it, and npm install downloads its dependencies.
sail conn init "saas-connectivity-demo"
cd saas-connectivity-demo
npm install
A SaaS connector is a small TypeScript app that ISC runs for you. ISC never calls the demo API itself — it sends your connector a command such as "list every account", and your connector turns that into HTTP calls and hands the results back in the shape ISC expects. What you just generated is a complete but nearly empty version of that app: the build scripts, a local dev server, and one placeholder command are wired up, and the translation between the two systems is left to you. Everything from here is you filling in that translation, one command at a time.
Three files matter:
| File | What it does |
|---|---|
connector-spec.json | Tells ISC which commands you support, what config to prompt for, and your schemas |
src/my-client.ts | Your HTTP calls to the demo API |
src/index.ts | Maps the demo API's data onto the std:* commands |
4. Define the connector spec
The spec is your connector's contract with ISC, and ISC reads it before any of your code runs. It answers three questions: which commands ISC is allowed to send you, which configuration fields to show an admin setting up a source, and what shape the data you return will be in. Code you write in later steps has no effect until it is declared here.
The scaffold ships a placeholder spec describing a fictional system, so you swap it for one that
describes the demo system. Open connector-spec.json in your editor and replace its entire contents
with the following:
{
"name": "SaaS Connectivity Demo",
"keyType": "simple",
"commands": [
"std:test-connection",
"std:account:list",
"std:account:read",
"std:entitlement:list",
"std:entitlement:read"
],
"supportsStatefulCommands": true,
"showDebugLoggingOption": true,
"sourceConfigInitialValues": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1"
},
"sourceConfig": [
{
"type": "menu",
"label": "Configuration",
"items": [
{
"type": "section",
"sectionTitle": "Demo API connection",
"sectionHelpMessage": "Create a demo API key in the SaaS Connectivity Demo web app. Keys and their data expire 7 days after creation.",
"docLink": "https://developer.sailpoint.com/docs/connectivity/saas-connectivity",
"items": [
{
"key": "baseUrl",
"label": "Demo API base URL",
"type": "url",
"required": true,
"placeholder": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1"
},
{
"key": "apiKey",
"label": "Demo API key",
"type": "secret",
"required": true,
"placeholder": "sck_..."
},
{
"key": "spConnEnableStatefulCommands",
"label": "Enable delta aggregation",
"type": "checkbox",
"required": false
}
]
}
]
}
],
"accountSchema": {
"displayAttribute": "displayName",
"identityAttribute": "id",
"groupAttribute": "groups",
"attributes": [
{
"name": "id",
"type": "string",
"description": "Opaque unique identifier assigned by the source"
},
{
"name": "userName",
"type": "string",
"description": "Login name, unique within the source"
},
{
"name": "displayName",
"type": "string",
"description": "Full name as shown in the source UI"
},
{
"name": "firstName",
"type": "string",
"description": "Given name"
},
{
"name": "lastName",
"type": "string",
"description": "Family name"
},
{
"name": "email",
"type": "string",
"description": "Primary email address"
},
{
"name": "department",
"type": "string",
"description": "Department the account belongs to"
},
{
"name": "title",
"type": "string",
"description": "Job title"
},
{
"name": "manager",
"type": "string",
"description": "The manager's account id, or null for none"
},
{
"name": "employeeId",
"type": "string",
"description": "HR employee number"
},
{
"name": "location",
"type": "string",
"description": "Primary office location"
},
{
"name": "costCenter",
"type": "string",
"description": "Cost center code used for chargeback"
},
{
"name": "phone",
"type": "string",
"description": "Contact phone number"
},
{
"name": "active",
"type": "boolean",
"description": "Whether the account is active. Note this is the inverse of the ISC \"disabled\" flag."
},
{
"name": "locked",
"type": "boolean",
"description": "Whether the account is locked out of the source"
},
{
"name": "groups",
"type": "string",
"entitlement": true,
"managed": true,
"multi": true,
"description": "Entitlement ids the account is a member of, as an array of strings"
}
]
},
"entitlementSchemas": [
{
"type": "group",
"displayAttribute": "name",
"identityAttribute": "id",
"includePermissions": true,
"attributes": [
{
"name": "id",
"type": "string",
"description": "Opaque unique identifier assigned by the source (grp_...)"
},
{
"name": "name",
"type": "string",
"description": "Short group name"
},
{
"name": "displayName",
"type": "string",
"description": "Human-readable group name"
},
{
"name": "description",
"type": "string",
"description": "What access the group grants"
},
{
"name": "status",
"type": "string",
"description": "Group lifecycle status (active or deprecated)"
},
{
"name": "created",
"type": "string",
"description": "ISO-8601 creation time"
},
{
"name": "updated",
"type": "string",
"description": "ISO-8601 last modification time"
}
]
}
]
}
Key things to note about what was changed from the scaffold:
-
commands—std:entitlement:listandstd:entitlement:readare added. ISC will only invoke commands listed here. -
sourceConfig—baseUrl(typeurl) andapiKey(typesecret) are added so ISC renders the right form fields when an admin configures the source. Keys map directly to whatreadConfig()returns in your code. -
accountSchema—identityAttributepoints toid(the opaqueusr_...key),groupAttributepoints togroups, and thegroupsattribute is flaggedentitlement: true,multi: true, andmanaged: trueso ISC treats its values as entitlements it can add and remove. -
entitlementSchemas— one entry oftype: "group"withincludePermissions: true, telling ISC to request and store permission detail from your connector.
5. Implement std:test-connection
This is the first of four commands you will implement, and the smallest — a good way to prove your credentials, your build, and your test loop all work before writing anything harder. ISC calls it when an admin clicks Test Connection while configuring a source, and it is the only signal they get that the base URL and API key they typed are right. Return an empty object on success, or throw — there is no "false" to return.
Endpoint: GET /v1/health. It is authenticated on purpose, so a bad key fails the test rather
than reporting a healthy source.
Start in src/my-client.ts. This file is where the HTTP calls happen to communicate with the actual source. Everything
else calls methods on it. It currently holds a scaffolded example client, so replace its entire
contents with the code below, which builds one reusable HTTP client from the baseUrl and apiKey
the admin entered in the source config. The SDK's client already handles auth headers, retries, and
logging, so do not add Axios yourself:
import {AxiosInstance, createConnectorHttpClient} from '@sailpoint/connector-sdk'
export class MyClient {
private readonly httpClient: AxiosInstance
constructor(config: any) {
this.httpClient = createConnectorHttpClient({
baseURL: config.baseUrl,
auth: {type: 'bearer', token: config.apiKey},
})
}
}
Every later call is then a relative path off baseURL — this.httpClient.get('/health').
Now add a testConnection() method inside the MyClient class — below the constructor, above the
class's closing brace. It calls GET /health and returns {} on success, so the happy path is two
lines:
async testConnection(): Promise<any> {
await this.httpClient.get('/health')
return {}
}
That is enough to pass the test. For a friendlier failure, wrap the call in a try/catch and, on a
401, throw a ConnectorError with a message an admin can act on — the See details section at
the end of this step shows that version.
Now wire it up in src/index.ts. That file is your connector's entry point: ISC loads it, gives it
the source config, and dispatches every incoming command to a handler registered there. The
scaffolded version registers handlers for a fictional system, so replace its entire contents with
the shell below. It reads the config, builds one MyClient from it, and registers a single handler
for std:test-connection, leaving you one line to write inside that handler — call
myClient.testConnection() and pass what it returns to res.send():
import {
Context,
createConnector,
readConfig,
Response,
StdTestConnectionInput,
StdTestConnectionOutput,
} from '@sailpoint/connector-sdk'
import {MyClient} from './my-client'
export const connector = async () => {
const config = await readConfig()
const myClient = new MyClient(config)
return createConnector()
.stdTestConnection(
async (context: Context, input: StdTestConnectionInput, res: Response<StdTestConnectionOutput>) => {
// call myClient.testConnection() and pass the result to res.send()
}
)
}
Each command you add in later steps chains another .stdSomething() onto that same createConnector() call.
Now test it without deploying anything. In your terminal, run:
npm run dev
That starts a local server at http://localhost:3000 which accepts the same command payloads ISC
sends, and reloads every time you save a file — so leave it running for the rest of the walkthrough.
Open a second terminal window, or open Postman. Since the first is busy running the server, and send the server a
std:test-connection command yourself. This is the same request ISC would make, so if it works here
it will work in your tenant — and you get an answer in a second instead of after a deploy:
- curl
- Postman
curl -sX POST localhost:3000 -H "Content-Type: application/json" -d '{
"type": "std:test-connection",
"input": {},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}'
Open Connector Commands → Test local stdTestConnection and replace the body with:
{
"type": "std:test-connection",
"input": {},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}
You should get back a response like this:
{
"data": {},
"type": "output"
}
See details: step recap and the full code
You replaced src/my-client.ts with a client that builds one authenticated HTTP client out of the
source config and added a testConnection() method to it, then replaced src/index.ts with an entry
point that registers a single std:test-connection handler. Both files in full — this version also
catches the 401 and reports it in language an admin can act on:
src/my-client.ts:
import {AxiosInstance, ConnectorError, createConnectorHttpClient} from '@sailpoint/connector-sdk'
export class MyClient {
private readonly httpClient: AxiosInstance
constructor(config: any) {
if (!config?.baseUrl || !config?.apiKey) {
throw new ConnectorError('baseUrl and apiKey must be provided from config')
}
this.httpClient = createConnectorHttpClient({
baseURL: config.baseUrl,
auth: {type: 'bearer', token: config.apiKey},
})
}
async testConnection(): Promise<any> {
try {
await this.httpClient.get('/health')
return {}
} catch (error: any) {
if (error?.response?.status === 401) {
throw new ConnectorError('The demo API rejected this key. Demo keys expire after 7 days.')
}
throw error
}
}
}
src/index.ts:
import {
Context,
createConnector,
readConfig,
Response,
StdTestConnectionInput,
StdTestConnectionOutput,
} from '@sailpoint/connector-sdk'
import {MyClient} from './my-client'
export const connector = async () => {
const config = await readConfig()
const myClient = new MyClient(config)
return createConnector()
.stdTestConnection(
async (context: Context, input: StdTestConnectionInput, res: Response<StdTestConnectionOutput>) => {
res.send(await myClient.testConnection())
}
)
}
6. Implement std:account:list
This is the command that gets identity data into ISC. Aggregation is the run — scheduled, or started by hand — where ISC asks a source for its accounts and reconciles what comes back against the identities it already knows about, so this handler is what an admin actually triggers when they click Start Aggregation. Yours does full aggregation: every account in the source, every time, with no attempt to send only what changed. (More on what ISC does with the results: loading account data.)
Endpoint: GET /v1/users. Two things to handle:
- It pages. Default page size is 10, capped at 100. Keep passing the
cursorfrom the last response back as a query parameter until nocursorcomes back. A page can have fewer items than you asked for and still not be the last one, so trust the cursor, not the count. emailis withheld from the list response, the way plenty of real APIs withhold contact details from bulk endpoints. Passinclude=emailfor now; making the per-account fan-out work is a stretch goal.
Back in my-client.ts, your handler needs every account, but one request returns only one page of
them. So add a getAllAccounts() method inside MyClient, below testConnection(), that does the
paging and hands back a single flat array — keeping the paging here is what lets the handler in
index.ts stay a simple loop. Each page of the response looks like this:
{
"items": [ ... ],
"cursor": "dXNlcl8w..."
}
When cursor is present in the response, pass it back as a query parameter on the next request to
get the next page. When the response has no cursor, you are on the last page. The loop structure
is a do...while — you always make at least one request first, then check whether to continue.
Paste the method below, then write the two missing lines in the loop body: one that appends this
page's response.data.items onto the accounts array, and one that sets cursor from
response.data.cursor, so the next pass either fetches another page or ends the loop.
async getAllAccounts(): Promise<any[]> {
const accounts: any[] = []
let cursor: string | undefined
do {
const params: any = {limit: 50, include: 'email'}
if (cursor) {
params.cursor = cursor
}
const response = await this.httpClient.get('/users', {params})
// push response.data.items onto accounts
// update cursor from response.data.cursor (undefined when there are no more pages)
} while (cursor)
return accounts
}
Now register the handler ISC will call. In index.ts, chain .stdAccountList() onto the same
createConnector() call from the last step — every command you implement adds another link to that
chain, and a command with no handler registered simply fails. The handler calls your client, then
translates each raw demo-API account into ISC's account shape.
The output is a stream, not an array: you call res.send() once per account inside the loop
rather than returning a list, which is what lets a source with 100,000 accounts aggregate without
holding them all in memory. Use the field mapping in the table below to fill in the attributes
block:
| Output field | From |
|---|---|
identity | account.id |
uuid | account.id |
attributes | Every attribute you declared in the account schema |
.stdAccountList(
async (context: Context, input: StdAccountListInput, res: Response<StdAccountListOutput>) => {
const accounts = await myClient.getAllAccounts()
for (const account of accounts) {
res.send({
identity: account.id,
uuid: account.id,
attributes: {
// map each attribute you declared in accountSchema
},
})
}
logger.info(`stdAccountList sent ${accounts.length} accounts`)
}
)
That snippet uses three things the file does not import yet, so add logger, StdAccountListInput,
and StdAccountListOutput to the existing import from @sailpoint/connector-sdk. The two Std...
names are the TypeScript types for what ISC sends this handler and what it expects back, so your
editor autocompletes the fields and flags a bad mapping before you deploy. logger writes to the
connector log, which is how you see what happened once the connector is running in your tenant
instead of on your laptop.
Save the files — the dev server reloads on its own — then send the new command from your second terminal:
- curl
- Postman
curl -sX POST localhost:3000 -H "Content-Type: application/json" -d '{
"type": "std:account:list",
"input": {},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}'
Open Connector Commands → Test local stdAccountList and replace the body with:
{
"type": "std:account:list",
"input": {},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}
The shipped body also sets "stateful": true, which asks for a delta run. Leaving it in does no
harm — your handler ignores it and returns everything — but delta aggregation is a stretch goal, so
clear it out to keep the request honest.
You want 50 objects, not 10 — if you get 10, your cursor loop is not looping. Each object in the stream will look like this:
{
"data": {
"identity": "usr_...",
"uuid": "usr_...",
"disabled": false,
"locked": false,
"attributes": {
"id": "usr_...",
"userName": "jane.doe",
"displayName": "Jane Doe",
...
}
},
"type": "output"
}
See details: step recap and the full code
You added a getAllAccounts() method that follows the demo API's cursor until there are no pages
left, and a stdAccountList handler that streams one res.send() per account with the attributes
you declared in the spec. Both pieces in full:
Add to src/my-client.ts:
// The demo API paginates by opaque cursor, so keep going until there isn't one.
async getAllAccounts(): Promise<any[]> {
const accounts: any[] = []
let cursor: string | undefined
do {
const params: any = {limit: 50, include: 'email'}
if (cursor) {
params.cursor = cursor
}
const response = await this.httpClient.get('/users', {params})
accounts.push(...response.data.items)
cursor = response.data.cursor
} while (cursor)
return accounts
}
Add to src/index.ts:
.stdAccountList(async (context: Context, input: StdAccountListInput, res: Response<StdAccountListOutput>) => {
const accounts = await myClient.getAllAccounts()
for (const account of accounts) {
res.send({
identity: account.id,
uuid: account.id,
disabled: !account.active,
locked: account.locked,
attributes: {
id: account.id,
userName: account.userName,
displayName: account.displayName,
firstName: account.firstName,
lastName: account.lastName,
email: account.email,
department: account.department,
title: account.title,
groups: account.groups,
},
})
}
logger.info(`stdAccountList sent ${accounts.length} accounts`)
})
Your imports from @sailpoint/connector-sdk now also need logger, StdAccountListInput, and
StdAccountListOutput.
7. Implement std:account:read
This step is optional. You can skip straight to step 8 and still end up with a fully working source.
Aggregation hands ISC the whole source at once, but plenty of things only need one account. ISC
calls std:account:read to refresh a single account — after a provisioning operation changed it, or
when someone clicks Reload Account — instead of re-reading all 50 to pick up one change. It
returns the same account shape as std:account:list, so most of this step is work you have already
done.
Endpoint: GET /v1/users/{id}. Same include=email caveat as the list.
Two things to get right:
- Finding the id.
input.keyis a union of simple and compound keys, so TypeScript will not let you reach straight forinput.key.simple.id— narrow it first. Fall back toinput.identityso your local test payloads work without akeyblock. - Missing accounts. An unknown id returns
404. Map that ontoConnectorErrorType.NotFound— that specific error is how ISC knows to fall through tostd:account:createinstead of failing the whole operation. Anything else you throw is a hard failure.
In my-client.ts, add a getAccount() method inside MyClient, below getAllAccounts(). It is the
single-account counterpart to that method: one request to GET /v1/users/{id} with include=email,
returning one account. Paste the code below, then replace the TODO comment with the missing check —
if error.response.status is 404, throw a ConnectorError whose second argument is
ConnectorErrorType.NotFound, and let anything else rethrow untouched:
async getAccount(id: string): Promise<any> {
try {
const response = await this.httpClient.get(`/users/${id}`, {
params: {include: 'email'},
})
return response.data
} catch (error: any) {
// TODO: if error.response.status === 404, throw ConnectorError with ConnectorErrorType.NotFound
throw error
}
}
Then chain the handler onto createConnector() in index.ts, the same way you did for
stdAccountList. Pulling the id out of the input is the tricky part. input.key is typed as a union,
because some sources identify an account with a single value and others with a combination of
several, so TypeScript will not let you read .simple.id until you have proved which of the two you
are holding — that is all the 'simple' in input.key check is doing. The input.identity fallback
covers the local test payloads below, which send a bare identity and no key block:
.stdAccountRead(
async (context: Context, input: StdAccountReadInput, res: Response<StdAccountReadOutput>) => {
const id = input.key && 'simple' in input.key ? input.key.simple.id : input.identity
const account = await myClient.getAccount(id)
res.send({
identity: account.id,
uuid: account.id,
attributes: {
// same attributes as stdAccountList
},
})
}
)
Two more imports. ConnectorErrorType in my-client.ts is the enum that holds NotFound, the
value telling ISC it hit a missing account rather than a broken connector. StdAccountReadInput and
StdAccountReadOutput in index.ts are this command's input and output types, exactly like the
StdAccountList... pair in the last step.
Then test with an id from the demo app's Accounts table:
- curl
- Postman
curl -sX POST localhost:3000 -H "Content-Type: application/json" -d '{
"type": "std:account:read",
"input": {"identity": "usr_..."},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}'
Open Connector Commands → Test local stdAccountRead and replace the body with:
{
"type": "std:account:read",
"input": {
"identity": "usr_..."
},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}
Swap in a real usr_... id from the demo app's Accounts table.
Then try a made-up id and confirm you get a notFound error rather than a generic one.
See details: step recap and the full code
You added a getAccount() method that fetches one account and turns a 404 into
ConnectorErrorType.NotFound, and a stdAccountRead handler that narrows input.key to find the id
before calling it. Both pieces in full:
Add to src/my-client.ts:
async getAccount(id: string): Promise<any> {
try {
const response = await this.httpClient.get(`/users/${id}`, {
params: {include: 'email'},
})
return response.data
} catch (error: any) {
// Let ISC fall through to std:account:create when the account is gone.
if (error?.response?.status === 404) {
throw new ConnectorError(`Account ${id} not found`, ConnectorErrorType.NotFound)
}
throw error
}
}
Add to src/index.ts:
.stdAccountRead(async (context: Context, input: StdAccountReadInput, res: Response<StdAccountReadOutput>) => {
const id = input.key && 'simple' in input.key ? input.key.simple.id : input.identity
const account = await myClient.getAccount(id)
res.send({
identity: account.id,
uuid: account.id,
disabled: !account.active,
locked: account.locked,
attributes: {
id: account.id,
userName: account.userName,
displayName: account.displayName,
firstName: account.firstName,
lastName: account.lastName,
email: account.email,
department: account.department,
title: account.title,
groups: account.groups,
},
})
})
my-client.ts now also needs ConnectorErrorType in its imports, and index.ts needs
StdAccountReadInput and StdAccountReadOutput.
8. Implement std:entitlement:list
Same shape as std:account:list, but for groups — and this is the one to run first in ISC, so
accounts have something to correlate their groups values against.
Endpoint: GET /v1/groups. 8 entitlements fit in one page at limit=100, so no cursor loop is
needed. Pass includePermissions=true to get permission detail back; without it the field is
withheld, mirroring the schema flag ISC passes you.
In my-client.ts, add a getAllEntitlements() method inside MyClient, below getAccount(). It is
the groups equivalent of getAllAccounts() and much simpler, because all 8 groups fit in one page
and there is no cursor to follow. Paste the code below and replace the TODO comment with the two
query parameters: limit: 100 and includePermissions: true.
async getAllEntitlements(): Promise<any[]> {
const response = await this.httpClient.get('/groups', {
// TODO: pass limit and includePermissions as params
})
return response.data.items
}
Then chain the handler in index.ts. Each res.send() needs a few extra fields compared to
accounts — use the table below to fill in the object:
| Output field | Value |
|---|---|
identity, uuid | entitlement.id |
type | 'group' — must match the type in your entitlement schema |
deleted | false |
attributes | The attributes you declared in the entitlement schema |
permissions | entitlement.permissions |
.stdEntitlementList(
async (context: Context, input: StdEntitlementListInput, res: Response<StdEntitlementListOutput>) => {
const entitlements = await myClient.getAllEntitlements()
for (const entitlement of entitlements) {
res.send({
identity: entitlement.id,
uuid: entitlement.id,
type: 'group',
deleted: false,
attributes: {
// map each attribute you declared in entitlementSchemas
},
permissions: entitlement.permissions,
})
}
logger.info(`stdEntitlementList sent ${entitlements.length} entitlements`)
}
)
Add StdEntitlementListInput and StdEntitlementListOutput to your imports from
@sailpoint/connector-sdk — this command's input and output types, the same pattern as the last two
steps. logger is already imported from step 6.
Save, then test from your second terminal:
- curl
- Postman
curl -sX POST localhost:3000 -H "Content-Type: application/json" -d '{
"type": "std:entitlement:list",
"input": {"type": "group"},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}'
Open Connector Commands → Test local stdEntitlementList and replace the body with:
{
"type": "std:entitlement:list",
"input": {
"type": "group"
},
"config": {
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
}
You want 8 objects, with permissions populated on a few of them. Each object in the stream will
look like this:
{
"data": {
"identity": "grp_...",
"uuid": "grp_...",
"type": "group",
"deleted": false,
"attributes": {
"id": "grp_...",
"name": "engineering",
"displayName": "Engineering",
"description": "Access to engineering systems"
},
"permissions": []
},
"type": "output"
}
See details: step recap and the full code
You added a single-request getAllEntitlements() method and a stdEntitlementList handler that
streams each group along with its type, deleted flag, and permissions. Both pieces in full:
Add to src/my-client.ts:
async getAllEntitlements(): Promise<any[]> {
const response = await this.httpClient.get('/groups', {
params: {limit: 100, includePermissions: true},
})
return response.data.items
}
Add to src/index.ts:
.stdEntitlementList(
async (context: Context, input: StdEntitlementListInput, res: Response<StdEntitlementListOutput>) => {
const entitlements = await myClient.getAllEntitlements()
for (const entitlement of entitlements) {
res.send({
identity: entitlement.id,
uuid: entitlement.id,
key: SimpleKey(entitlement.id),
type: 'group',
deleted: false,
attributes: {
id: entitlement.id,
name: entitlement.name,
displayName: entitlement.displayName,
description: entitlement.description,
},
permissions: entitlement.permissions,
})
}
logger.info(`stdEntitlementList sent ${entitlements.length} entitlements`)
}
)
index.ts now also needs StdEntitlementListInput and StdEntitlementListOutput.
9. Deploy to your tenant
Everything so far has run on your laptop. These commands put the connector in your tenant so ISC can
run it. In your terminal — the second one, not the one running npm run dev — paste them one at a
time. The first bundles your code into a zip, the second registers a connector under that name in
your org, and the third uploads the zip to that registration.
npm run pack-zip
sail conn create "saas-connectivity-demo"
sail conn upload -c saas-connectivity-demo -f dist/saas-connectivity-demo-0.1.0.zip
The zip filename comes from name and version in package.json — run ls dist if you are not sure.
Now run a command against the uploaded copy instead of the local one. When ISC runs your connector
it passes in the config an admin filled out on the source form, but you have no source yet — so the
CLI needs somewhere else to read those values from. Create a config.json in the project root with
the same keys your sourceConfig declares; it stands in for that form:
{
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
Then, in your terminal, run:
sail conn invoke account-list -c saas-connectivity-demo -p config.json
config.json is only used for CLI runs like this one — it is not part of the uploaded zip. It holds
a live key, so do not commit it.
See details: the config file and debugging commands
config.json:
{
"baseUrl": "https://dugfer5z7k.execute-api.us-east-1.amazonaws.com/v1",
"apiKey": "sck_your_key_here"
}
# What did I upload?
sail conn list
sail conn tags list -c saas-connectivity-demo
# Watch the connector's logs in your org
sail conn logs tail
# Read-only integration tests
sail conn validate -p config.json -c saas-connectivity-demo -r
10. Create the source and aggregate
In your tenant, go to Admin → Connections → Sources → Create New, search for
SaaS Connectivity Demo, and click the Configure button.
- Give the source a name, description and assign an owner.
- On the configuration screen, paste your Demo API base URL and Demo API key.
- Click Test Connection in the "Review and Test" screen
- Go to Entitlement Management → Entitlement Aggregation and click the "Start Aggregation" button — entitlements first, so accounts have groups to correlate against.
- Then go to Account Management → Account Aggregation and click the "Start Aggregation" button.
You should see 8 entitlements and 50 accounts land in ISC. Open an account and check the attributes
came through the way you mapped them, and that its entitlements resolved to group names rather than
raw grp_... ids.
11. Stretch goals
Pick whichever looks most interesting. Each one maps to something real sources actually do.
- Make the email fan-out real. Drop
include=emailfrom your account list call. The demo API withholds email from bulk responses, exactly like other sources do, so you have to fetch/users/{id}/emailsper account — usePromise.allto avoid doing it serially. - Add provisioning. Implement
std:account:create,std:account:update,std:account:enable,std:account:disable, andstd:account:unlock. Watch theAttributeChangesemantics:Setreplaces a whole multi-valued list,Addonly works on multi-valued attributes, and an update that changes nothing must return{}. - Add delta aggregation. Set
supportsStatefulCommandsin your spec, then use the demo API'supdatedSincefilter withres.saveState(). Six of the seeded accounts have recent timestamps, so a delta run returns a genuine subset. - Handle failure properly. Create a second key with
curl -X POST "$DEMO_API/v1/keys?readOnly=true". Every write then returns403. Note thatConnectorErrorTypeonly hasGenericandNotFound— for anything else you throw aConnectorErrorwith a message worth reading, or define your own subclasses as the error handling guide shows. - Populate a dropdown. Implement
std:source-data:discoverandstd:source-data:readso a source config field offers the demo system's real departments and locations. (useful for custom forms)
Documentation
- SaaS Connectivity overview
- Prerequisites — set this up before you start
- Build a basic connector
- Connector commands
- Connector spec
- Test, build, and deploy
- Common CLI commands
- Postman collection
- Example connectors
Demo system reference
- Open the demo app — create a key, seed and edit data, browse the schema
- OpenAPI description — every endpoint and command
- The app's API page has copy-paste
curlrecipes for all fifteen commands, pre-filled with the right base URL.
Still stuck?
Grab a SailPoint developer advocate in the room, or reach for the community and tooling.