Skip to main content

← All Hack Day tracks

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​

brew tap sailpoint-oss/tap && brew install sailpoint-cli

Confirm it landed and that the version is 2.4.0 or newer:

sail --version
warning

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:all and inherits your own access, which is what the connector commands in step 9 need — pick narrower scopes and those commands will fail with a 403.
  • 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 where npm run dev listens, so the URLs need no editing.
  • The only thing you must change is the config block. The collection ships with a generic {"token": "apikey"} placeholder — replace it with the baseUrl and apiKey your 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.

caution

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:

FileWhat it does
connector-spec.jsonTells ISC which commands you support, what config to prompt for, and your schemas
src/my-client.tsYour HTTP calls to the demo API
src/index.tsMaps 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:list and std:entitlement:read are added. ISC will only invoke commands listed here.

  • sourceConfig — baseUrl (type url) and apiKey (type secret) are added so ISC renders the right form fields when an admin configures the source. Keys map directly to what readConfig() returns in your code.

  • accountSchema — identityAttribute points to id (the opaque usr_... key), groupAttribute points to groups, and the groups attribute is flagged entitlement: true, multi: true, and managed: true so ISC treats its values as entitlements it can add and remove.

  • entitlementSchemas — one entry of type: "group" with includePermissions: true, telling ISC to request and store permission detail from your connector.

  • Connector spec reference

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 -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"
}
}'

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 cursor from the last response back as a query parameter until no cursor comes 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.
  • email is withheld from the list response, the way plenty of real APIs withhold contact details from bulk endpoints. Pass include=email for 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 fieldFrom
identityaccount.id
uuidaccount.id
attributesEvery 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 -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"
}
}'

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​

Short on time?

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.key is a union of simple and compound keys, so TypeScript will not let you reach straight for input.key.simple.id — narrow it first. Fall back to input.identity so your local test payloads work without a key block.
  • Missing accounts. An unknown id returns 404. Map that onto ConnectorErrorType.NotFound — that specific error is how ISC knows to fall through to std:account:create instead 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 -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"
}
}'

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 fieldValue
identity, uuidentitlement.id
type'group' — must match the type in your entitlement schema
deletedfalse
attributesThe attributes you declared in the entitlement schema
permissionsentitlement.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 -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"
}
}'

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.

  1. Give the source a name, description and assign an owner.
  2. On the configuration screen, paste your Demo API base URL and Demo API key.
  3. Click Test Connection in the "Review and Test" screen
  4. Go to Entitlement Management → Entitlement Aggregation and click the "Start Aggregation" button — entitlements first, so accounts have groups to correlate against.
  5. 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=email from your account list call. The demo API withholds email from bulk responses, exactly like other sources do, so you have to fetch /users/{id}/emails per account — use Promise.all to avoid doing it serially.
  • Add provisioning. Implement std:account:create, std:account:update, std:account:enable, std:account:disable, and std:account:unlock. Watch the AttributeChange semantics: Set replaces a whole multi-valued list, Add only works on multi-valued attributes, and an update that changes nothing must return {}.
  • Add delta aggregation. Set supportsStatefulCommands in your spec, then use the demo API's updatedSince filter with res.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 returns 403. Note that ConnectorErrorType only has Generic and NotFound — for anything else you throw a ConnectorError with a message worth reading, or define your own subclasses as the error handling guide shows.
  • Populate a dropdown. Implement std:source-data:discover and std:source-data:read so a source config field offers the demo system's real departments and locations. (useful for custom forms)

Documentation​

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 curl recipes 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.