openapi: 3.0.3
info:
  title: SailPoint SaaS Connectivity Demo API
  version: 1.0.0
  description: |
    An ephemeral target system for practicing [SailPoint SaaS
    Connectivity](https://developer.sailpoint.com/docs/connectivity/saas-connectivity)
    connector development. Create an API key, seed some data, and point a
    connector at it — no infrastructure of your own required.

    **Everything here expires.** An API key and all data beneath it are deleted
    7 days after the key is created. This is a training sandbox; never store real
    data in it.

    ## Two layers, one dataset

    The API deliberately exposes the same data twice.

    **Layer A — the target system** (`/v1/users`, `/v1/groups`, ...) behaves like
    a real SaaS application you have been asked to integrate. This is what your
    connector should call. It has the awkward edges real sources have: it
    withholds `email` from bulk responses, it paginates in pages of 10, its PUT
    replaces rather than merges, and its group ids are opaque and unrelated to
    group names.

    **Layer B — the command facade** (`/v1/commands/{command}`) accepts the
    envelope the connector CLI sends and returns the documented `Std*Output`
    shapes. Use it to see what your handler *should* have produced for a given
    input, then go and make Layer A produce it.

    ## Getting started

    ```bash
    API=https://example.execute-api.us-east-1.amazonaws.com
    KEY=$(curl -sX POST "$API/v1/keys" | jq -r .apiKey)
    curl -sX POST "$API/v1/seed"       -H "Authorization: Bearer $KEY"
    curl -s     "$API/v1/users?limit=10" -H "Authorization: Bearer $KEY"
    ```

    ## Error codes

    Each status maps onto a distinct connector error, so you can practice
    handling them:

    | Status | `code` | Maps to |
    | --- | --- | --- |
    | 400 | `invalidRequest` | `InvalidRequestError` |
    | 401 | `invalidCredentials` | `InvalidConfigurationError` |
    | 403 | `insufficientPermission` | `InsufficientPermissionError` |
    | 404 | `notFound` | `ConnectorErrorType.NotFound` |
    | 409 | `conflict` | source-side validation failure |
    | 409 | `limitExceeded` | the key holds the maximum number of records |
    | 413 | `limitExceeded` | the request body is over 64 KB |
    | 429 | `tooManyRequests` | retry with backoff, honouring `Retry-After` |

    ## Limits

    The sandbox is open to anyone, so it caps what one caller can use:

    | Limit | Value |
    | --- | --- |
    | Requests per second, per client IP | 10 |
    | API keys created per hour, per client IP | 30 |
    | Accounts per key | 200 |
    | Entitlements per key | 50 |
    | Permissions per entitlement | 20 |
    | Length of a string attribute | 256 characters (1024 for `description`) |
    | Request body | 64 KB |

    The two per-IP limits are deployment parameters, so your values can differ.

    Create a key with `?readOnly=true` to get one that returns 403 on every
    write — the only practical way to exercise permission handling.
  contact:
    name: SailPoint Developer Relations
    url: https://developer.sailpoint.com
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://your-api-id.execute-api.us-east-1.amazonaws.com
    description: Replace with the ApiEndpoint output of your deployed stack
  - url: http://localhost:3000
    description: Local development server (npm run dev)

tags:
  - name: API keys
    description: Self-service, anonymous, 7-day API keys. Each one is a private sandbox.
  - name: Seed data
    description: Populate or clear the baseline dataset.
  - name: Accounts
    description: The target system's account API — what your connector calls.
  - name: Entitlements
    description: The target system's group API. ISC only supports the `group` type.
  - name: Schema
    description: What this source advertises, plus a ready-made connector spec.
  - name: Commands
    description: The `std:*` command facade.

security:
  - bearerAuth: []
  - apiKeyAuth: []

paths:
  /:
    get:
      tags: [API keys]
      summary: Describe the service
      description: Unauthenticated service description, so a client can self-configure.
      security: []
      operationId: getServiceDescription
      responses:
        '200':
          description: Service description
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: {type: string}
                  description: {type: string}
                  documentation: {type: string, format: uri}
                  keyLifetimeDays: {type: integer, example: 7}
                  commands:
                    type: array
                    items: {type: string}
        '429': {$ref: '#/components/responses/TooManyRequests'}

  /v1/keys:
    post:
      tags: [API keys]
      summary: Create an API key
      description: |
        Mints a new key and its private data partition. No signup and no
        credentials. One client IP can create 30 keys per hour.

        The key is shown **once** — there is no endpoint that returns it again.
        If you lose it, create another.
      security: []
      operationId: createApiKey
      parameters:
        - name: readOnly
          in: query
          required: false
          schema: {type: boolean, default: false}
          description: >-
            When true, the key returns 403 on every write. Use it to test `InsufficientPermissionError` handling.
      responses:
        '201':
          description: Key created
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CreatedKey'}
        '429': {$ref: '#/components/responses/TooManyRequests'}

  /v1/keys/current:
    get:
      tags: [API keys]
      summary: Describe the calling key
      description: Counts are computed live, so they reflect any records you have added or deleted.
      operationId: getCurrentKey
      responses:
        '200':
          description: Key status
          content:
            application/json:
              schema: {$ref: '#/components/schemas/KeyStatus'}
        '401': {$ref: '#/components/responses/Unauthorized'}
    delete:
      tags: [API keys]
      summary: Delete the calling key and all of its data
      description: >-
        Immediate, rather than waiting for the TTL. Everything under the key is removed and the key stops working.
      operationId: deleteCurrentKey
      responses:
        '200':
          description: Key and data deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: {type: boolean}
                  itemsDeleted: {type: integer, example: 59}
        '401': {$ref: '#/components/responses/Unauthorized'}

  /v1/health:
    get:
      tags: [Accounts]
      summary: Health check
      description: |
        The cheap endpoint to call from `std:test-connection`.

        It is authenticated on purpose: a health check that ignored credentials
        would let a misconfigured source report a successful test connection.
      operationId: getHealth
      responses:
        '200':
          description: The key is valid and the source is reachable
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: {type: string, example: ok}
                  expiresAt: {type: string, format: date-time}
                  readOnly: {type: boolean}
        '401': {$ref: '#/components/responses/Unauthorized'}

  /v1/seed:
    post:
      tags: [Seed data]
      summary: Seed the baseline dataset
      description: |
        Writes 50 accounts and 8 entitlements.

        The dataset is shaped for teaching value: five pages at the default page
        size, four inactive accounts, two locked ones, a deprecated group,
        accounts holding exactly one entitlement, and six accounts with recent
        `updated` timestamps so delta aggregation returns a real subset.

        Idempotent — seeding twice does nothing unless you pass `reset`.
      operationId: seedData
      parameters:
        - name: reset
          in: query
          required: false
          schema: {type: boolean, default: false}
          description: Delete existing accounts and entitlements before seeding.
      responses:
        '200':
          description: Already seeded; nothing changed
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SeedResult'}
        '201':
          description: Seeded
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SeedResult'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /v1/seed/reset:
    post:
      tags: [Seed data]
      summary: Delete all accounts and entitlements
      description: Clears the data but leaves the key itself valid.
      operationId: resetData
      responses:
        '200':
          description: Data cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  reset: {type: boolean}
                  accountsDeleted: {type: integer}
                  entitlementsDeleted: {type: integer}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}

  /v1/users:
    get:
      tags: [Accounts]
      summary: List accounts
      description: |
        Paginated, in pages of 10 by default.

        `email` is **not** included. Real sources routinely withhold contact
        details from bulk endpoints, which forces a connector to fan out a second
        call per account during aggregation — see
        `GET /v1/users/{id}/emails`. Pass `include=email` to skip that lesson.

        Continue paging while a `cursor` is present. A page can contain fewer
        than `limit` items and still not be the last one, because `updatedSince`
        is applied after the page is read — the same behaviour a
        DynamoDB-backed source would show you.
      operationId: listAccounts
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: updatedSince
          in: query
          required: false
          schema: {type: string, format: date-time}
          description: >-
            Return only accounts modified strictly after this instant. This is what makes delta aggregation (`stateful` + `res.saveState`) demonstrable.
          example: '2026-08-01T00:00:00.000Z'
        - name: include
          in: query
          required: false
          schema: {type: string, enum: [email]}
          description: Include a normally withheld field. Only `email` is supported.
      responses:
        '200':
          description: One page of accounts
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: {$ref: '#/components/schemas/Account'}
                  cursor:
                    type: string
                    description: Pass back as `cursor` to fetch the next page. Absent on the last page.
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
    post:
      tags: [Accounts]
      summary: Create an account
      description: >-
        `userName` and `email` are required. Unknown attributes are rejected rather than silently dropped. A single entitlement may be sent as a bare string; several as an array.
      operationId: createAccount
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AccountWrite'}
            examples:
              minimal:
                summary: Minimum viable account
                value:
                  userName: jane.doe
                  email: jane.doe@sailpointdemo.com
              full:
                summary: Fully populated
                value:
                  userName: jane.doe
                  email: jane.doe@sailpointdemo.com
                  firstName: Jane
                  lastName: Doe
                  displayName: Jane Doe
                  department: Engineering
                  title: Staff Engineer
                  employeeId: E10099
                  location: Austin, TX
                  costCenter: CC-1000
                  phone: '+1-555-0199'
                  active: true
                  locked: false
                  groups: [grp_a1b2c3d4e5f6]
      responses:
        '201':
          description: Created. Includes `email`, since the caller just supplied it.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Account'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/Conflict'}

  /v1/users/{id}:
    parameters:
      - $ref: '#/components/parameters/AccountId'
    get:
      tags: [Accounts]
      summary: Read one account
      description: >-
        404s an unknown id — map this onto `ConnectorErrorType.NotFound` so ISC falls through to `std:account:create`. `email` is withheld unless requested.
      operationId: getAccount
      parameters:
        - name: include
          in: query
          required: false
          schema: {type: string, enum: [email]}
      responses:
        '200':
          description: The account
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Account'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '404': {$ref: '#/components/responses/NotFound'}
    patch:
      tags: [Accounts]
      summary: Partially update an account
      description: Only the attributes you send are changed.
      operationId: patchAccount
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AccountWrite'}
            examples:
              title:
                summary: Change one attribute
                value: {title: Developer Advocate}
      responses:
        '200':
          description: The updated account
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Account'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/Conflict'}
    put:
      tags: [Accounts]
      summary: Replace an account
      description: |
        **Full replace, not a merge.** Any attribute you omit reverts to its
        default — `groups` becomes empty, strings become blank.

        This is the difference from PATCH that catches out connectors written
        against a source the author assumed merged. Reach for PATCH unless you
        genuinely mean to overwrite everything.
      operationId: replaceAccount
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AccountWrite'}
      responses:
        '200':
          description: The replaced account
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Account'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/Conflict'}
    delete:
      tags: [Accounts]
      summary: Delete an account
      description: >-
        Hard delete. Note that ISC never sends `std:account:delete` automatically — only a `BeforeProvisioning` rule triggers it.
      operationId: deleteAccount
      responses:
        '204':
          description: Deleted
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/users/{id}/emails:
    parameters:
      - $ref: '#/components/parameters/AccountId'
    get:
      tags: [Accounts]
      summary: Read an account's email addresses
      description: >-
        The second call your connector must make, because the list and read endpoints withhold `email`. Modelled on Discourse's `/u/{username}/emails.json`, which the SailPoint docs use as the worked example for fanning out with `Promise.all`.
      operationId: getAccountEmails
      responses:
        '200':
          description: Email addresses
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: {type: string}
                  userName: {type: string}
                  primary: {type: string, format: email}
                  emails:
                    type: array
                    items: {type: string, format: email}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/users/{id}/enable:
    parameters:
      - $ref: '#/components/parameters/AccountId'
    post:
      tags: [Accounts]
      summary: Enable an account
      description: Sets `active` to true. Idempotent; `changed` reports whether anything moved.
      operationId: enableAccount
      responses:
        '200': {$ref: '#/components/responses/AccountStatusChanged'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/users/{id}/disable:
    parameters:
      - $ref: '#/components/parameters/AccountId'
    post:
      tags: [Accounts]
      summary: Disable an account
      description: >-
        Sets `active` to false. This is the only command ISC sends on a leaver event. Remember `disabled` in `Std*Output` is the negation of `active`.
      operationId: disableAccount
      responses:
        '200': {$ref: '#/components/responses/AccountStatusChanged'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/users/{id}/unlock:
    parameters:
      - $ref: '#/components/parameters/AccountId'
    post:
      tags: [Accounts]
      summary: Unlock an account
      description: Sets `locked` to false. ISC supports unlock only — sources do their own locking.
      operationId: unlockAccount
      responses:
        '200': {$ref: '#/components/responses/AccountStatusChanged'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/users/{id}/lock:
    parameters:
      - $ref: '#/components/parameters/AccountId'
    post:
      tags: [Accounts]
      summary: Lock an account
      description: >-
        Has no ISC equivalent. It exists so you can put an account into the locked state and then test your unlock handler against it.
      operationId: lockAccount
      responses:
        '200': {$ref: '#/components/responses/AccountStatusChanged'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/users/{id}/password:
    parameters:
      - $ref: '#/components/parameters/AccountId'
    post:
      tags: [Accounts]
      summary: Change an account's password
      description: |
        Validates the password (minimum 8 characters) and **discards it**.
        Nothing is stored: the sandbox has no authentication of its own, and
        keeping even a demo password would be a liability for no teaching
        benefit.

        The account's `updated` timestamp does move, so delta aggregation still
        notices the change.

        ISC pre-validates against the source's password policy, so your connector
        should not re-validate.
      operationId: changeAccountPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: {type: string, minLength: 8, format: password}
      responses:
        '200':
          description: Password accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: {type: string}
                  userName: {type: string}
                  passwordChanged: {type: boolean}
                  updated: {type: string, format: date-time}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/groups:
    get:
      tags: [Entitlements]
      summary: List entitlements
      description: >-
        `permissions` are withheld unless `includePermissions=true`, mirroring the schema flag ISC passes so a connector can skip work it was not asked to do.
      operationId: listEntitlements
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IncludePermissions'
      responses:
        '200':
          description: One page of entitlements
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: {$ref: '#/components/schemas/Entitlement'}
                  cursor: {type: string}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
    post:
      tags: [Entitlements]
      summary: Create an entitlement
      operationId: createEntitlement
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EntitlementWrite'}
            examples:
              withPermissions:
                summary: Group carrying permissions
                value:
                  name: auditors
                  displayName: Auditors
                  description: Read the audit trail
                  permissions:
                    - target: AUDIT_LOG
                      rights: read,export
                      annotation: Read and export the full audit trail
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Entitlement'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/Conflict'}

  /v1/groups/{id}:
    parameters:
      - $ref: '#/components/parameters/EntitlementId'
    get:
      tags: [Entitlements]
      summary: Read one entitlement
      operationId: getEntitlement
      parameters:
        - $ref: '#/components/parameters/IncludePermissions'
      responses:
        '200':
          description: The entitlement
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Entitlement'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '404': {$ref: '#/components/responses/NotFound'}
    patch:
      tags: [Entitlements]
      summary: Partially update an entitlement
      operationId: patchEntitlement
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EntitlementWrite'}
      responses:
        '200':
          description: The updated entitlement
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Entitlement'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/Conflict'}
    delete:
      tags: [Entitlements]
      summary: Delete an entitlement
      description: >-
        Also strips the group from every account that referenced it, so no account is left pointing at a group that no longer exists.
      operationId: deleteEntitlement
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: {type: boolean}
                  accountsUpdated:
                    type: integer
                    description: How many accounts had the group removed.
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/groups/{id}/permissions:
    parameters:
      - $ref: '#/components/parameters/EntitlementId'
    get:
      tags: [Entitlements]
      summary: Read an entitlement's permissions
      description: >-
        The extra call `includePermissions` exists to let a connector avoid. Permissions are not a separate entitlement type — ISC models them as an array nested inside a group.
      operationId: getEntitlementPermissions
      responses:
        '200':
          description: Permissions
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: {type: string}
                  name: {type: string}
                  permissions:
                    type: array
                    items: {$ref: '#/components/schemas/Permission'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/groups/{id}/members:
    parameters:
      - $ref: '#/components/parameters/EntitlementId'
    get:
      tags: [Entitlements]
      summary: List an entitlement's members
      description: >-
        Membership from the group's side, for connectors that aggregate entitlements first and derive account access from them.
      operationId: listEntitlementMembers
      responses:
        '200':
          description: Members
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: {type: string}
                  memberCount: {type: integer}
                  members:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string}
                        userName: {type: string}
                        displayName: {type: string}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/groups/{id}/members/{userId}:
    parameters:
      - $ref: '#/components/parameters/EntitlementId'
      - name: userId
        in: path
        required: true
        schema: {type: string}
        example: usr_a1b2c3d4e5f6
    put:
      tags: [Entitlements]
      summary: Add an account to an entitlement
      description: >-
        A real membership call rather than an attribute write. Idempotent — adding twice reports `changed: false`.
      operationId: addEntitlementMember
      responses:
        '200': {$ref: '#/components/responses/MembershipChanged'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
    delete:
      tags: [Entitlements]
      summary: Remove an account from an entitlement
      operationId: removeEntitlementMember
      responses:
        '200': {$ref: '#/components/responses/MembershipChanged'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}

  /v1/schema/accounts:
    get:
      tags: [Schema]
      summary: Account schema
      description: >-
        What `std:account:discover-schema` should return, and the authoritative list of writable account attributes.
      operationId: getAccountSchema
      responses:
        '200':
          description: Account schema
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AccountSchema'}
        '401': {$ref: '#/components/responses/Unauthorized'}

  /v1/schema/entitlements:
    get:
      tags: [Schema]
      summary: Entitlement schema
      operationId: getEntitlementSchema
      responses:
        '200':
          description: Entitlement schema
          content:
            application/json:
              schema: {$ref: '#/components/schemas/EntitlementSchema'}
        '401': {$ref: '#/components/responses/Unauthorized'}

  /v1/schema/connector-spec:
    get:
      tags: [Schema]
      summary: A ready-made connector-spec.json
      description: >-
        A complete connector spec pointed at this deployment, so you can start from something that works instead of writing one from the docs. Includes `sourceConfig`, `accountSchema`, `entitlementSchemas` and an `accountCreateTemplate`.
      operationId: getConnectorSpec
      responses:
        '200':
          description: Connector spec
          content:
            application/json:
              schema: {type: object, additionalProperties: true}
        '401': {$ref: '#/components/responses/Unauthorized'}

  /v1/commands:
    get:
      tags: [Commands]
      summary: List supported commands
      operationId: listCommands
      responses:
        '200':
          description: Supported commands
          content:
            application/json:
              schema:
                type: object
                properties:
                  commands:
                    type: array
                    items:
                      type: object
                      properties:
                        command: {type: string, example: 'std:account:list'}
                        path: {type: string, example: /v1/commands/account-list}
                        streaming: {type: boolean}
                        write: {type: boolean}
        '401': {$ref: '#/components/responses/Unauthorized'}

  /v1/commands/{command}:
    post:
      tags: [Commands]
      summary: Invoke a connector command
      description: |
        Accepts the envelope the connector CLI sends and returns the documented
        `Std*Output` shape. Use it to check what your handler should have
        produced.

        The command may be spelled as a path segment (`account-list`), with the
        `std:` prefix url-encoded (`std%3Aaccount%3Alist`), or with colons
        (`account%3Alist`).

        `input` may be omitted for commands that take none
        (`test-connection`, `account-discover-schema`), and a bare input object
        is accepted in place of the full envelope.

        ### Differences from the target-system layer, on purpose

        - `email` is always populated, because a correct connector would have
          made the second call to fetch it.
        - `account-list` and `entitlement-list` respond with newline-delimited
          JSON (`application/x-ndjson`), one `{"data": ..., "type": "output"}`
          object per line, matching what a local connector run emits. Add
          `?format=json` for a plain array.
        - For a stateful `account-list`, a final
          `{"data": {"date": "..."}, "type": "state"}` line shows the value to
          hand to `res.saveState()`. That trailing line is a convenience of this
          sandbox, not part of the SDK wire format.
        - An `account-update` that changes nothing responds with `{}`, as the
          docs require.
      operationId: invokeCommand
      parameters:
        - name: command
          in: path
          required: true
          schema:
            type: string
            enum:
              - test-connection
              - account-list
              - account-read
              - account-create
              - account-update
              - account-delete
              - account-enable
              - account-disable
              - account-unlock
              - account-discover-schema
              - entitlement-list
              - entitlement-read
              - change-password
              - source-data-discover
              - source-data-read
        - name: format
          in: query
          required: false
          schema: {type: string, enum: [json]}
          description: >-
            For the streaming commands, return a JSON array instead of newline-delimited JSON.
      requestBody:
        required: false
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CommandEnvelope'}
            examples:
              testConnection:
                summary: 'std:test-connection'
                value: {type: 'std:test-connection'}
              accountList:
                summary: 'std:account:list — full aggregation'
                value: {type: 'std:account:list', input: {}}
              accountListDelta:
                summary: 'std:account:list — delta aggregation'
                value:
                  type: 'std:account:list'
                  input:
                    stateful: true
                    state: {date: '2026-08-01T00:00:00.000Z'}
              accountRead:
                summary: 'std:account:read'
                value:
                  type: 'std:account:read'
                  input:
                    identity: usr_a1b2c3d4e5f6
                    key: {simple: {id: usr_a1b2c3d4e5f6}}
              accountCreate:
                summary: 'std:account:create'
                value:
                  type: 'std:account:create'
                  input:
                    attributes:
                      userName: jane.doe
                      email: jane.doe@sailpointdemo.com
                      firstName: Jane
                      lastName: Doe
                      password: a-generated-password
                      groups: grp_a1b2c3d4e5f6
              accountUpdate:
                summary: 'std:account:update — Set, Add and Remove together'
                value:
                  type: 'std:account:update'
                  input:
                    identity: usr_a1b2c3d4e5f6
                    changes:
                      - {op: Set, attribute: title, value: Developer Advocate}
                      - {op: Add, attribute: groups, value: [grp_engineering, grp_support]}
                      - {op: Remove, attribute: groups, value: grp_moderator}
              entitlementList:
                summary: 'std:entitlement:list — with permissions'
                value:
                  type: 'std:entitlement:list'
                  input:
                    type: group
                    schema: {type: string, includePermissions: true}
              changePassword:
                summary: 'std:change-password'
                value:
                  type: 'std:change-password'
                  input:
                    identity: usr_a1b2c3d4e5f6
                    password: a-new-password
              sourceDataRead:
                summary: 'std:source-data:read'
                value:
                  type: 'std:source-data:read'
                  input:
                    sourceDataKey: departments
                    queryInput: {query: fetchAll, limit: 10}
      responses:
        '200':
          description: |
            Command output. The shape depends on the command:

            - `test-connection`, `account-delete`, `change-password` → `{}`
            - `account-read`, `account-create`, `account-enable`,
              `account-disable`, `account-unlock` → a `StdAccountOutput`
            - `account-update` → a `StdAccountOutput`, or `{}` if nothing changed
            - `account-list`, `entitlement-list` → newline-delimited JSON
            - `account-discover-schema` → the account schema
            - `source-data-discover`, `source-data-read` → an array of
              `{key, label, subLabel}`
          content:
            application/json:
              schema:
                oneOf:
                  - {$ref: '#/components/schemas/StdAccountOutput'}
                  - {$ref: '#/components/schemas/StdEntitlementOutput'}
                  - {$ref: '#/components/schemas/AccountSchema'}
                  - type: array
                    items: {$ref: '#/components/schemas/SourceDataSet'}
                  - type: object
                    description: Empty object, for commands with no output.
            application/x-ndjson:
              schema: {$ref: '#/components/schemas/StreamedOutput'}
        '201':
          description: Account created by `account-create`
          content:
            application/json:
              schema: {$ref: '#/components/schemas/StdAccountOutput'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409': {$ref: '#/components/responses/Conflict'}

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'Send the demo key as `Authorization: Bearer sck_...`.'
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: >-
        Alternative to the bearer token, so the connector SDK's `apiKey` auth type works unchanged.

  parameters:
    Limit:
      name: limit
      in: query
      required: false
      schema: {type: integer, minimum: 1, maximum: 100, default: 10}
      description: >-
        Page size. Defaults to 10 and is capped at 100, so pagination has to be handled rather than accidentally working.
    Cursor:
      name: cursor
      in: query
      required: false
      schema: {type: string}
      description: >-
        Opaque continuation token from the previous page's `cursor`. Keep paging while one is present.
    IncludePermissions:
      name: includePermissions
      in: query
      required: false
      schema: {type: boolean, default: false}
      description: >-
        Include the nested `permissions` array. Mirrors the entitlement schema flag ISC passes.
    AccountId:
      name: id
      in: path
      required: true
      schema: {type: string}
      description: The account's opaque id, not its `userName`.
      example: usr_a1b2c3d4e5f6
    EntitlementId:
      name: id
      in: path
      required: true
      schema: {type: string}
      description: The entitlement's opaque id, not its `name`.
      example: grp_a1b2c3d4e5f6

  responses:
    BadRequest:
      description: The request was malformed or failed validation
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Problem'}
          example:
            status: 400
            code: invalidRequest
            message: The request body is not valid.
            details: ['"userName" is required and must be non-empty.']
    Unauthorized:
      description: The API key is missing, unrecognized or expired
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Problem'}
          example:
            status: 401
            code: invalidCredentials
            message: 'This API key is not recognized. It may have expired.'
    Forbidden:
      description: The API key is read-only
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Problem'}
          example:
            status: 403
            code: insufficientPermission
            message: This API key is read-only and cannot modify data.
    NotFound:
      description: No such record
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Problem'}
          example:
            status: 404
            code: notFound
            message: 'Account "usr_a1b2c3d4e5f6" was not found.'
    Conflict:
      description: >-
        The change would violate a uniqueness constraint (`conflict`), or the key already holds the maximum number of records (`limitExceeded`)
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Problem'}
          example:
            status: 409
            code: conflict
            message: 'An account with userName "jane.doe" already exists.'
    TooManyRequests:
      description: >-
        Rate limited: more than 10 requests per second, or more than 30 new keys per hour, from one client IP. Retry after the interval in `Retry-After`.
      headers:
        Retry-After:
          schema: {type: integer}
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Problem'}
    AccountStatusChanged:
      description: The account, plus whether the call actually changed anything
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Account'
              - type: object
                properties:
                  changed:
                    type: boolean
                    description: False when the account was already in the requested state.
    MembershipChanged:
      description: Membership after the change
      content:
        application/json:
          schema:
            type: object
            properties:
              entitlementId: {type: string}
              accountId: {type: string}
              member: {type: boolean}
              changed: {type: boolean}
              groups:
                type: array
                items: {type: string}

  schemas:
    CreatedKey:
      type: object
      description: >-
        The only response that ever contains the key. There is no endpoint that returns it again.
      properties:
        apiKey:
          type: string
          pattern: '^sck_[A-Za-z0-9_-]{32}$'
          example: sck_Xy3kP9mQr7TnVw2LzBc5Hd8FgJ4NsA6e
        createdAt: {type: string, format: date-time}
        expiresAt: {type: string, format: date-time}
        expiresInDays: {type: integer, example: 7}
        readOnly: {type: boolean}
        warning: {type: string}

    KeyStatus:
      type: object
      properties:
        createdAt: {type: string, format: date-time}
        expiresAt: {type: string, format: date-time}
        readOnly: {type: boolean}
        seeded: {type: boolean}
        accountCount: {type: integer}
        entitlementCount: {type: integer}
        secondsRemaining: {type: integer}

    SeedResult:
      type: object
      properties:
        seeded:
          type: boolean
          description: False when the tenant already had data and `reset` was not passed.
        accounts: {type: integer, example: 50}
        entitlements: {type: integer, example: 8}
        message: {type: string}

    Account:
      type: object
      description: >-
        An account in the demo system. `email` is present only when explicitly requested, or on a create response.
      properties:
        id:
          type: string
          readOnly: true
          description: >-
            Opaque surrogate key assigned by the source. Deliberately different from `userName`, because real sources have both and you have to pick one as the ISC account identity.
          example: usr_a1b2c3d4e5f6
        userName: {type: string, example: alan.bradley}
        displayName: {type: string, example: Alan Bradley}
        firstName: {type: string, example: Alan}
        lastName: {type: string, example: Bradley}
        email:
          type: string
          format: email
          description: Withheld from list and read responses unless `include=email`.
          example: alan.bradley@sailpointdemo.com
        department: {type: string, example: Engineering}
        title: {type: string, example: Staff Engineer}
        manager:
          type: string
          nullable: true
          description: Another account's `id`, or null.
          example: usr_9f8e7d6c5b4a
        employeeId: {type: string, example: E10001}
        location: {type: string, example: 'Austin, TX'}
        costCenter: {type: string, example: CC-1000}
        phone: {type: string, example: '+1-555-0100'}
        active:
          type: boolean
          description: 'Note: `Std*Output.disabled` is the negation of this field.'
        locked: {type: boolean}
        groups:
          type: array
          description: Entitlement ids, not names.
          items: {type: string, example: grp_a1b2c3d4e5f6}
        created: {type: string, format: date-time, readOnly: true}
        updated: {type: string, format: date-time, readOnly: true}

    AccountWrite:
      type: object
      description: >-
        Writable account attributes. `id`, `created` and `updated` are managed by the source and ignored if sent. Unknown attributes are rejected.
      properties:
        userName: {type: string}
        displayName:
          type: string
          description: Derived from firstName and lastName on create when omitted.
        firstName: {type: string}
        lastName: {type: string}
        email: {type: string, format: email}
        department: {type: string}
        title: {type: string}
        manager: {type: string, nullable: true}
        employeeId: {type: string}
        location: {type: string}
        costCenter: {type: string}
        phone: {type: string}
        active: {type: boolean}
        locked: {type: boolean}
        groups:
          description: >-
            Entitlement ids. A single value may be sent as a bare string, which is how ISC sends one entitlement; several must be an array.
          oneOf:
            - type: string
            - type: array
              items: {type: string}

    Permission:
      type: object
      required: [target, rights]
      properties:
        target:
          type: string
          description: The resource or scope the rights apply to.
          example: SYSADMIN
        rights:
          type: string
          description: Comma-separated rights.
          example: read,write,delete
        annotation:
          type: string
          description: Human-readable description.
          example: Unrestricted system administration

    Entitlement:
      type: object
      properties:
        id:
          type: string
          readOnly: true
          description: Opaque id, deliberately unrelated to `name`.
          example: grp_a1b2c3d4e5f6
        name: {type: string, example: admin}
        displayName: {type: string, example: Administrator}
        description: {type: string}
        type:
          type: string
          enum: [group]
          description: ISC only supports the `group` type.
        status: {type: string, enum: [active, deprecated]}
        permissions:
          type: array
          description: Present only when `includePermissions=true`.
          items: {$ref: '#/components/schemas/Permission'}
        created: {type: string, format: date-time, readOnly: true}
        updated: {type: string, format: date-time, readOnly: true}

    EntitlementWrite:
      type: object
      properties:
        name: {type: string}
        displayName: {type: string, description: Defaults to `name` on create.}
        description: {type: string}
        status: {type: string, enum: [active, deprecated], default: active}
        permissions:
          type: array
          items: {$ref: '#/components/schemas/Permission'}

    SchemaAttribute:
      type: object
      properties:
        name: {type: string}
        type: {type: string, enum: [string, boolean, long, int]}
        description: {type: string}
        entitlement: {type: boolean, description: Holds entitlement values.}
        managed: {type: boolean, description: ISC may provision changes to it.}
        multi: {type: boolean, description: Holds a list of values.}

    AccountSchema:
      type: object
      description: What `std:account:discover-schema` returns.
      properties:
        displayAttribute:
          type: string
          description: Becomes the ISC "Account Name".
          example: displayName
        identityAttribute:
          type: string
          description: Becomes the ISC "Account ID". Must be globally unique.
          example: id
        groupAttribute:
          type: string
          description: Names the multi-valued attribute holding entitlement values.
          example: groups
        attributes:
          type: array
          items: {$ref: '#/components/schemas/SchemaAttribute'}

    EntitlementSchema:
      type: object
      properties:
        type: {type: string, enum: [group]}
        displayAttribute: {type: string, example: name}
        identityAttribute: {type: string, example: id}
        includePermissions: {type: boolean}
        attributes:
          type: array
          items: {$ref: '#/components/schemas/SchemaAttribute'}

    AttributeChange:
      type: object
      required: [op, attribute]
      description: |
        One ISC provisioning change.

        - **Set** overwrites. On a multi-valued attribute the whole list is
          replaced, not merged.
        - **Add** appends, and is only valid on a multi-valued attribute.
        - **Remove** subtracts from a multi-valued attribute, and clears a
          single-valued one to null.
      properties:
        op: {type: string, enum: [Set, Add, Remove]}
        attribute: {type: string, example: groups}
        value:
          description: >-
            A bare string for one value, an array for several. Omit entirely with Remove to clear the attribute.
          oneOf:
            - type: string
            - type: boolean
            - type: array
              items: {type: string}

    CommandEnvelope:
      type: object
      description: >-
        The envelope the connector CLI sends. A bare input object is also accepted, since the command is already unambiguous from the path.
      properties:
        type:
          type: string
          description: Ignored — the command comes from the path. Accepted so CLI payloads paste in unchanged.
          example: 'std:account:list'
        input:
          type: object
          description: >-
            The command's Std*Input. Fields vary by command; the ones common enough to be worth naming are listed here.
          additionalProperties: true
          properties:
            identity:
              type: string
              description: The account or entitlement id.
            key: {$ref: '#/components/schemas/SimpleKey'}
            attributes:
              type: object
              additionalProperties: true
              description: Used by account-create.
            changes:
              type: array
              description: Used by account-update.
              items: {$ref: '#/components/schemas/AttributeChange'}
            password:
              type: string
              format: password
              description: Used by change-password.
            stateful:
              type: boolean
              description: Used by the list commands to request a delta run.
            state:
              type: object
              additionalProperties: true
              description: The cursor returned by the previous stateful run.
            schema:
              type: object
              additionalProperties: true
              description: Used by entitlement-list, carrying includePermissions.
            sourceDataKey:
              type: string
              description: Used by source-data-read.
            queryInput:
              type: object
              additionalProperties: true
              description: Used by the source-data commands to filter and limit.
        config:
          type: object
          additionalProperties: true
          description: Ignored. Accepted so CLI payloads paste in unchanged.

    SimpleKey:
      type: object
      properties:
        simple:
          type: object
          properties:
            id: {type: string}

    StdAccountOutput:
      type: object
      description: The resource object shape shared by the account commands.
      properties:
        identity: {type: string, example: usr_a1b2c3d4e5f6}
        uuid: {type: string, example: usr_a1b2c3d4e5f6}
        key: {$ref: '#/components/schemas/SimpleKey'}
        disabled:
          type: boolean
          description: The negation of the source's `active` field.
        locked: {type: boolean}
        attributes:
          type: object
          additionalProperties: true
          description: Every account attribute, including `email`.

    StdEntitlementOutput:
      type: object
      properties:
        identity: {type: string, example: grp_a1b2c3d4e5f6}
        uuid: {type: string, example: grp_a1b2c3d4e5f6}
        key: {$ref: '#/components/schemas/SimpleKey'}
        type: {type: string, enum: [group]}
        deleted: {type: boolean}
        attributes:
          type: object
          additionalProperties: true
        permissions:
          type: array
          description: Present only when the input asked for permissions.
          items: {$ref: '#/components/schemas/Permission'}

    StreamedOutput:
      type: object
      description: >-
        One line of a newline-delimited list response. `type` is `output` for each object, and `state` for the optional trailing delta cursor.
      properties:
        data:
          type: object
          additionalProperties: true
        type: {type: string, enum: [output, state]}

    SourceDataSet:
      type: object
      description: One entry of a source-data discover or read response.
      properties:
        key: {type: string, example: departments}
        label: {type: string, example: Departments}
        subLabel: {type: string, example: '12 accounts'}

    Problem:
      type: object
      description: Every error response uses this shape.
      required: [status, code, message]
      properties:
        status: {type: integer, example: 404}
        code:
          type: string
          enum:
            - invalidRequest
            - invalidCredentials
            - insufficientPermission
            - notFound
            - conflict
            - limitExceeded
            - tooManyRequests
            - internalError
        message: {type: string}
        details:
          type: array
          description: Per-field detail for validation failures.
          items: {type: string}
