# SCIM custom integration

This guide is for developers building a custom SCIM client against Agility Credit — for example, an in-house IdP, a middleware that bridges a non-standard system, or automated provisioning scripts. If you're integrating with Okta or Entra ID, use the IdP-specific instructions in [SCIM provisioning](https://apidocs.agilitycredit.com/sso/scim-provisioning) instead.

Agility's SCIM endpoint implements the [SCIM 2.0 protocol](https://datatracker.ietf.org/doc/html/rfc7644) with the same operation surface that Okta's provisioning client uses. Any client that speaks Okta-style SCIM 2.0 will work; this page documents the exact contract.

## Prerequisites

- An Agility tenant with **SCIM provisioning** enabled (Settings → Account → Identity → SCIM).
- An Agility API key, created in Settings → Account → API Keys. The key is the SCIM bearer token.
- Your tenant ID (visible in the SCIM endpoint URL shown in the portal).

## Base URL and authentication

| Environment | Base URL                                            |
| ----------- | --------------------------------------------------- |
| Production  | `https://api.agilitycredit.net/scim/v2/<tenant-id>` |
| UAT         | `https://api.agc-dev.com/scim/v2/<tenant-id>`       |

All requests must include:

```
Authorization: Bearer <api-key>
Content-Type: application/scim+json
Accept: application/scim+json
```

Auth failures return `401` without a SCIM error body. When SCIM is disabled on the tenant, every endpoint returns `503` with detail `SCIM provisioning is disabled for this tenant`.

:::tip Use a dedicated API key
Create a separate key for SCIM so it can be rotated independently from keys used by other integrations.
:::

## ServiceProviderConfig

`GET /ServiceProviderConfig` advertises which optional SCIM features Agility supports. Mirror these flags in your client:

| Feature          | Supported | Notes                                                     |
| ---------------- | --------- | --------------------------------------------------------- |
| `patch`          | Yes       | PATCH is the preferred way to do partial updates          |
| `bulk`           | No        | Send individual requests instead                          |
| `filter`         | Yes       | `maxResults` = 200; only on `/Users` and `/Groups`        |
| `changePassword` | No        | Passwords are managed by the underlying identity provider |
| `sort`           | No        | Do not send `sortBy` or `sortOrder`                       |
| `etag`           | No        | `If-Match` / `If-None-Match` headers are ignored          |
| `groups`         | Yes       | Groups are **virtual** — see [Groups](#groups)            |

Authentication scheme is `oauthbearertoken` (the SCIM term for "Bearer in `Authorization` header").

## Schemas

Agility implements the SCIM 2.0 core User and Group schemas, with a small set of opinionated constraints. Fetch the live schemas at runtime when in doubt:

- `GET /Schemas` — list of all schemas
- `GET /Schemas/urn:ietf:params:scim:schemas:core:2.0:User`
- `GET /Schemas/urn:ietf:params:scim:schemas:core:2.0:Group`

### User schema highlights

| Attribute         | Mutability    | Notes                                                                                 |
| ----------------- | ------------- | ------------------------------------------------------------------------------------- |
| `id`              | readOnly      | Server-assigned UUID. Use it for all subsequent operations.                           |
| `externalId`      | readWrite     | Client-assigned correlation ID. Stored verbatim.                                      |
| `userName`        | **immutable** | Must be the user's work email. Cannot be changed after creation.                      |
| `emails`          | immutable     | Primary work email; same value as `userName`.                                         |
| `name.givenName`  | readWrite     | Required.                                                                             |
| `name.familyName` | readWrite     | Required.                                                                             |
| `phoneNumbers`    | readWrite     | `value` must be **E.164** (e.g. `+14155550100`). Invalid values are silently dropped. |
| `roles`           | readWrite     | See [Roles](#roles). Only honoured when role mapping source is `idp_attribute`. The attribute name carrying the values is tenant-configurable — `roles` is just the default. |
| `active`          | readWrite     | `false` deactivates (soft-delete); `true` reactivates a previously deactivated user.  |

`userName` is the only attribute suitable for **unique-identifier matching** in your client. Matching on `id` or `email` is unsafe — `id` doesn't exist until after creation, and using `email` directly can produce duplicates if attribute mapping changes.

### Roles

Roles are carried by a multi-valued attribute. Allowed `value`s:

- `admin`
- `user`
- `sales`
- `sales_with_credit`
- `api`
- `accounting`

Unknown role values are filtered out silently. Roles are only applied to the user when the tenant's role mapping is configured as **From IdP attribute**; in all other modes the role attribute on incoming payloads is ignored (the request still succeeds).

#### Configurable attribute name

The SCIM attribute the server reads role values from is **tenant-configurable**. It defaults to the standard `roles` attribute, but a tenant can configure it to any name (typically the local part of an enterprise-extension attribute such as `agcRoles`) so that an IdP can push roles through whatever mapping they already use.

There's no SCIM-side discovery for the configured name — confirm it with the tenant admin before integrating. If you can't, sending the values under `roles` is the safest default and will work for any tenant that hasn't overridden it.

The configured name applies to **both** POST/PUT bodies and PATCH paths. For PATCH, the canonical `roles` path is always accepted in addition to the configured name, so a client that always uses `roles` will still work when the tenant hasn't overridden the default.

Agility accepts the role attribute in either of two shapes, matching what real-world IdPs send:

```json
"roles": ["admin", "sales"]
```

```json
"roles": [{ "value": "admin" }, { "value": "sales" }]
```

## Users

### List users — `GET /Users`

Supports filtering. Pagination parameters (`startIndex`, `count`) are accepted but the endpoint returns the full filtered set in one response (capped at `filter.maxResults` = 200).

Filterable attributes: `userName`, `emails.value`, `name.givenName`, `name.familyName`, `externalId`. Supported operators: `eq`, `co` (contains), `sw` (starts with). Unsupported filter attributes are logged as a warning but the request still succeeds (returning all users).

```bash
curl "https://api.agilitycredit.net/scim/v2/<tenant-id>/Users?filter=userName%20eq%20%22jane%40acme.com%22" \
  -H "Authorization: Bearer <api-key>"
```

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 1,
  "startIndex": 1,
  "itemsPerPage": 1,
  "Resources": [
    {
      "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
      "id": "6f0e9c6a-1b5a-4f0a-8e3b-1a2c3d4e5f60",
      "userName": "jane@acme.com",
      "name": { "givenName": "Jane", "familyName": "Doe", "formatted": "Jane Doe" },
      "emails": [{ "value": "jane@acme.com", "type": "work", "primary": true }],
      "roles": ["admin"],
      "active": true,
      "meta": { "resourceType": "User", "location": "/scim/v2/<tenant-id>/Users/6f0e9c6a-..." }
    }
  ]
}
```

### Get user — `GET /Users/{id}`

Returns the user resource. `404` if the user doesn't exist or is deactivated.

### Create user — `POST /Users`

```bash
curl -X POST "https://api.agilitycredit.net/scim/v2/<tenant-id>/Users" \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/scim+json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "jane@acme.com",
    "name": { "givenName": "Jane", "familyName": "Doe" },
    "emails": [{ "value": "jane@acme.com", "type": "work", "primary": true }],
    "phoneNumbers": [{ "value": "+14155550100", "type": "work" }],
    "roles": ["admin"],
    "active": true,
    "externalId": "acme-hr-1234"
  }'
```

Status codes:

- `201 Created` — new user provisioned.
- `200 OK` — user already existed (in Agility or as a deactivated record) and was **adopted** or **reactivated** by SCIM. Treat both as success; idempotent retries are safe.
- `400 invalidValue` — payload missing `userName`/email or schema validation failed.

The response body is the canonical SCIM user resource. Persist `id` from the response — it's the only stable handle for subsequent operations.

### Replace user — `PUT /Users/{id}`

PUT is a full-resource replace. Send the complete user representation. `userName` and `emails` are immutable: if you send a different value, the change is silently ignored (logged on the server side).

Setting `active: false` in a PUT deactivates the user — equivalent to `DELETE`.

### Update user — `PATCH /Users/{id}`

PATCH is the recommended way to do partial updates. Both pathed and Okta-style no-path operations are accepted:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    { "op": "replace", "path": "name.givenName", "value": "Janet" },
    { "op": "replace", "path": "active", "value": false }
  ]
}
```

Equivalent Okta-style form (the server normalises this into pathed ops):

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    {
      "op": "replace",
      "value": { "name.givenName": "Janet", "active": false }
    }
  ]
}
```

Supported paths:

| Path                                                 | Notes                                                                                                                                              |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `active`                                             | `false` deactivates, `true` reactivates                                                                                                            |
| `name.givenName`, `name.familyName`                  | Empty string is rejected (`400 invalidValue`)                                                                                                      |
| `phoneNumbers`, `phoneNumbers[type eq "work"].value` | Must be E.164; invalid values are dropped                                                                                                          |
| `userName`, `emails[type eq "work"].value`           | Ignored (immutable). Request still returns 200.                                                                                                    |
| `roles`, `roles[primary eq "true"].value`, _or the tenant-configured role attribute name_ | Only applied when role mapping source is `idp_attribute`. Replace-style ops with a `value` object support Entra's `SingleAppRoleAssignment` shape. |

Unsupported `op` values return `400 invalidValue`. Unknown paths are logged and ignored — the request still returns 200, which keeps strict IdP clients happy.

### Delete user — `DELETE /Users/{id}`

Soft-deletes (deactivates) the user. The record is retained so that a subsequent `POST /Users` with the same `userName` reactivates it rather than creating a duplicate. Returns `204 No Content`.

## Groups

Agility's groups are **virtual** — they're derived from the tenant's role mapping configuration, not stored independently. Each configured `(idp_group → role)` mapping shows up as one SCIM group whose `id` is the role name and `displayName` is the IdP group name. As a consequence:

- You **cannot** create or delete groups via SCIM. `POST /Groups` and `DELETE /Groups/{id}` succeed for known groups (idempotent) but never mutate the underlying mapping configuration.
- Group membership reflects which **SCIM-provisioned** users hold the role. Portal-managed users are never included and are never modified by group operations.
- Group operations are no-ops unless the tenant's role mapping source is **From IdP groups**. The endpoints still return `200`/`204` so strict clients don't break.

### List groups — `GET /Groups`

Optional `filter` parameter — only `displayName eq "<name>"` is honoured.

### Get group — `GET /Groups/{role}`

The path parameter is the role (`admin`, `user`, etc.), which is the group's `id`. `404` if the role isn't mapped.

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
  "id": "admin",
  "displayName": "Acme Admins",
  "members": [{ "value": "6f0e9c6a-1b5a-...", "display": "jane@acme.com" }],
  "meta": { "resourceType": "Group", "location": "/scim/v2/<tenant-id>/Groups/admin" }
}
```

### Create group — `POST /Groups`

A `displayName` matching a configured IdP group returns the existing virtual group with `201`. An unknown name returns `404`. No new mapping is created.

### Replace group — `PUT /Groups/{role}`

The `members` array is treated as the new full membership set: users not listed lose the role, users newly listed gain it. Only SCIM-provisioned users are touched.

### Update group — `PATCH /Groups/{role}`

Supports `add`, `remove`, and `replace` operations targeting `members`. Okta-style targeted remove (`"path": "members[value eq \"<user-id>\"]"`) works as well as the unpathed `remove` of the entire `members` collection.

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    { "op": "add", "path": "members", "value": [{ "value": "6f0e9c6a-..." }] },
    { "op": "remove", "path": "members[value eq \"a1b2c3d4-...\"]" }
  ]
}
```

`displayName` changes are ignored (logged). Group `id`/`displayName` are server-managed.

### Delete group — `DELETE /Groups/{role}`

Removes the role from every SCIM-provisioned user that holds it, but does not remove the mapping itself. Returns `204`.

## Errors

Error responses use the standard SCIM 2.0 error envelope:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "400",
  "detail": "userName or emails[0].value is required",
  "scimType": "invalidValue"
}
```

| Status | When                                                                           |
| ------ | ------------------------------------------------------------------------------ |
| 400    | Payload validation failed. `scimType: invalidValue` for schema/value problems. |
| 401    | API key missing or invalid. No SCIM error body — plain HTTP 401.               |
| 404    | User, group, or schema not found.                                              |
| 500    | Unexpected backend error. Retry-safe for idempotent verbs (GET, PUT, DELETE).  |
| 503    | SCIM is disabled on the tenant.                                                |

The endpoint deliberately tolerates a number of "harmless" client mistakes (unknown PATCH paths, attempts to change `userName`, unsupported filter attributes) so that strict IdP clients don't fail loudly. These are logged on the server and surface in the Activity tab — see below.

## Testing your integration

1. Enable SCIM on a test tenant and toggle **Log successful events (debug)** on. With debug on, every successful `GET` is recorded in addition to mutations.
2. Implement against `GET /ServiceProviderConfig` first — it confirms auth, base URL, and tenant ID in one call.
3. Drive a representative end-to-end flow: create a user, PATCH a field, deactivate, reactivate (via a fresh POST), and finally delete.
4. Watch the **Activity** tab on the Identity page. Each request appears with method, path, status code, and the full request/response body — useful for diagnosing attribute-mapping issues.
5. Turn debug off before going live to keep the log readable.

See [Troubleshooting](https://apidocs.agilitycredit.com/sso/troubleshooting) for common SCIM errors and how to interpret the activity log.

Source: https://apidocs.agilitycredit.com/sso/scim-provisioning/custom-integration
