> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.joincandidhealth.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.joincandidhealth.com/_mcp/server.

# Candid Health SCIM 2.0 API

Candid Health implements [SCIM 2.0](https://datatracker.ietf.org/doc/html/rfc7644) for programmatic user and organization provisioning. This document is the external reference for the SCIM endpoints.

## Contents

* [Quickstart](#quickstart)
* [Conventions](#conventions)
* [Authentication](#authentication)
* [Scoping](#scoping)
* [Discovery endpoints](#discovery-endpoints)
* [Users](#users)
* [Groups (Organizations)](#groups-organizations)
* [Role assignment](#role-assignment)
* [PATCH operations](#patch-operations)
* [Filtering and pagination](#filtering-and-pagination)
* [Errors](#errors)
* [IdP compatibility notes](#idp-compatibility-notes)
* [Idempotency](#idempotency)
* [Future features](#future-features)

---

## Quickstart

> **Note on terminology**: in this API, **Groups represent organizations**. The SCIM standard names the resource `Group`, so the endpoints are `/Groups`, but everywhere in this document "Group" and "organization" refer to the same thing.

### 1. Obtain a SCIM token

Contact Candid Health to provision a SCIM token for your account. You will be given:

* A bearer token (sent in the `Authorization` header)
* The base URL for the API (production and any non-production environments)
* The UUID of the organization the token is scoped to

The token grants access to that organization **and all of its descendants in the org hierarchy**. See [Scoping](#scoping).

### 2. Discover the organizations you can manage

```http
GET /scim/v2/Groups
Authorization: Bearer <token>
```

The response lists every organization in your token's scope. Record the `id` of any organization you'll need to reference when creating users or child organizations.

### 3. (Optional) Create child organizations

If your token is scoped to an enterprise organization, you can create child organizations under it:

```http
POST /scim/v2/Groups
```

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group", "urn:candid:scim:1.0:Organization"],
  "displayName": "Acme Cardiology Clinic",
  "urn:candid:scim:1.0:Organization": {
    "parentId": "<enterprise-org-id-from-step-2>"
  }
}
```

### 4. Provision users

```http
POST /scim/v2/Users
```

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:candid:scim:1.0:User"],
  "userName": "alex.morgan@example.com",
  "name": { "givenName": "Alex", "familyName": "Morgan" },
  "urn:candid:scim:1.0:User": {
    "organizationRoles": [
      {
        "organizationId": "<org-id-from-step-2-or-3>",
        "roles": ["Biller"]
      }
    ]
  }
}
```

### 5. Verify

Fetch the organization to see the user in its `members` list:

```http
GET /scim/v2/Groups/<org-id>
```

---

## Conventions

* **Base URL**: all SCIM endpoints are mounted at `/scim/v2`.
* **Content type**: SCIM requires `application/scim+json`. Standard `application/json` is also accepted on requests for convenience; responses always set `application/scim+json`.
* **IDs**: all resource identifiers (`id`, `parentId`, `organizationId`) are UUIDs.
* **Emails**: stored lowercase and compared case-insensitively. Mixed-case input is normalized on write.
* **Time**: ISO-8601 timestamps in UTC.
* **Request body size limit**: 100 KB. Larger bodies are rejected with `413 Payload Too Large`.
* **Rate limiting**: requests are rate-limited per source IP. Standard `RateLimit-*` response headers indicate remaining quota; exceeding the limit returns `429 Too Many Requests`.
* **Read-only attributes**: `id`, `meta`, `active` (User), and `externalId` (User) are server-managed. They are returned in responses but ignored on writes. User `externalId` is populated by the IdP (Auth0) on first login; it cannot be set by the provisioning client.
* **`schemas` array**: every request body must include a `schemas` array per RFC 7643. The array is not strictly validated — you may list only the resource's core schema (e.g. `["urn:ietf:params:scim:schemas:core:2.0:User"]`) even when using the Candid extension. We recommend listing every schema you reference for clarity and forward compatibility.
* **Unknown attributes are silently stripped.** Attributes that aren't part of the supported schemas (e.g. SCIM enterprise extensions, vendor-specific fields sent by your IdP) are silently dropped from request bodies — they will not appear on the resource and will not cause an error. This means typos in attribute names also go undetected; double-check spelling.

The full set of supported features is advertised by [`GET /scim/v2/ServiceProviderConfig`](#discovery-endpoints).

---

## Authentication

All SCIM requests must include a Bearer token issued by Candid Health:

```http
Authorization: Bearer <scim-token>
```

Tokens are scoped to a single organization. The scope of operations a token can perform is described in [Scoping](#scoping).

Authentication failure modes:

| Condition                                   | Status | Detail                                               |
| ------------------------------------------- | ------ | ---------------------------------------------------- |
| Missing or malformed `Authorization` header | `401`  | `Authorization header with Bearer token is required` |
| Token does not match a known active token   | `401`  | `Invalid bearer token`                               |
| Token is expired (`expires_at` in the past) | `401`  | `Token has expired`                                  |

---

## Scoping

Each SCIM token is bound to a single Candid organization. When the token is used, the server resolves that organization plus **all of its descendants in the organization hierarchy** as the token's allowed scope.

The scope is enforced on every endpoint:

* **Users** — a user is in scope if they have an active membership in any organization in the allowed scope. `GET /Users` returns only in-scope users; `GET /Users/:id`, `PUT /Users/:id`, `PATCH /Users/:id`, and `DELETE /Users/:id` return `403` for out-of-scope users.
* **Groups** — a group is in scope if its ID is in the allowed scope. `GET /Groups` filters to in-scope groups; the per-ID operations return `403` for out-of-scope groups.
* **Role assignments** — any `organizationId` referenced in a User create/update body must be in scope. The same applies to organization IDs embedded in legacy role values (`PRIMARY_ORG`, `ADDITIONAL_ORGS`).
* **Group parent** — `parentId` on create/replace must be in scope.

Out-of-scope references return `403 Forbidden` with detail `Organization is not within the scope of this SCIM token.` (or the User equivalent).

---

## Discovery endpoints

Standard SCIM discovery endpoints per RFC 7644 §4.

### `GET /scim/v2/ServiceProviderConfig`

Returns the provider's capability advertisement.

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
  "documentationUri": "https://datatracker.ietf.org/doc/html/rfc7644",
  "patch": { "supported": true },
  "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
  "filter": { "supported": true, "maxResults": 100 },
  "changePassword": { "supported": false },
  "sort": { "supported": false },
  "etag": { "supported": false },
  "authenticationSchemes": [
    {
      "type": "oauthbearertoken",
      "name": "OAuth Bearer Token",
      "description": "Authentication scheme using the OAuth Bearer Token Standard",
      "specUri": "https://www.rfc-editor.org/info/rfc6750"
    }
  ]
}
```

### `GET /scim/v2/ResourceTypes`

Lists the supported resource types: `User` and `Group`. Each declares its core schema and any extension schemas.

### `GET /scim/v2/Schemas`

Returns full attribute definitions for:

* `urn:ietf:params:scim:schemas:core:2.0:User`
* `urn:ietf:params:scim:schemas:core:2.0:Group`
* `urn:candid:scim:1.0:User` — Candid User extension
* `urn:candid:scim:1.0:Organization` — Candid Group/Organization extension

---

## Users

The User resource represents a person who can sign in. Schema URI: `urn:ietf:params:scim:schemas:core:2.0:User`.

### Attributes

| Attribute                                    | Type    | Required  | Notes                                                                                                                                                                            |
| -------------------------------------------- | ------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userName`                                   | string  | yes       | Email address. Must match `emails[0].value` when `emails` is present. Stored lowercase.                                                                                          |
| `externalId`                                 | string  | read-only | The user's Auth0 ID. Populated automatically on first login. Ignored if sent on create/replace/patch. Returned in GET responses once set; omitted before the user has logged in. |
| `name.givenName`                             | string  | yes       | First name.                                                                                                                                                                      |
| `name.familyName`                            | string  | yes       | Last name.                                                                                                                                                                       |
| `displayName`                                | string  | no        | Defaults to `"{givenName} {familyName}"` if omitted.                                                                                                                             |
| `emails`                                     | array   | no        | At most one entry; `emails[0].value` must equal `userName`. The `type` and `primary` fields are accepted but ignored — the address from `emails[0]` is always the primary.       |
| `active`                                     | boolean | read-only | `true` for active users. See [Deactivation](#deactivation).                                                                                                                      |
| `roles`                                      | array   | no        | Legacy role assignment format. See [Role assignment](#role-assignment).                                                                                                          |
| `urn:candid:scim:1.0:User.organizationRoles` | array   | no        | Modern role assignment format. Preferred.                                                                                                                                        |

### Candid User extension

```
urn:candid:scim:1.0:User
```

```json
{
  "organizationRoles": [
    {
      "organizationId": "11111111-1111-1111-1111-111111111111",
      "roles": ["Biller"]
    }
  ]
}
```

* `organizationId` — UUID of an organization the user should be a member of.
* `roles` — list of role names (display name or normalized name accepted) for that organization.

> The extension is **always included** in User responses (`GET /Users`, `GET /Users/:id`, and in the response bodies of `POST` / `PUT` / `PATCH`). You do not need to opt in via `attributes`.
>
> On `GET /Users`, roles are fetched per user in the returned page. Listing latency therefore scales with `count` — paginate with smaller page sizes if you're hitting wall-clock limits.
>
> See [Future features](#future-features) for the current limitation on per-organization role differentiation.

### `POST /scim/v2/Users` — create a user

```http
POST /scim/v2/Users
Content-Type: application/scim+json
Authorization: Bearer <token>
```

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:candid:scim:1.0:User"],
  "userName": "alex.morgan@example.com",
  "name": {
    "givenName": "Alex",
    "familyName": "Morgan"
  },
  "displayName": "Alex Morgan",
  "emails": [{ "value": "alex.morgan@example.com", "primary": true }],
  "urn:candid:scim:1.0:User": {
    "organizationRoles": [
      {
        "organizationId": "11111111-1111-1111-1111-111111111111",
        "roles": ["Biller"]
      }
    ]
  }
}
```

Response: `201 Created`, `Location: /scim/v2/Users/{id}`, body is the full User resource. `externalId` is omitted until the user completes their first login.

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "9c8b1a3e-5e2f-4b6e-9d8c-1f2a3b4c5d6e",
  "userName": "alex.morgan@example.com",
  "name": { "givenName": "Alex", "familyName": "Morgan" },
  "displayName": "Alex Morgan",
  "emails": [{ "value": "alex.morgan@example.com", "primary": true }],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2026-05-26T14:00:00.000Z",
    "lastModified": "2026-05-26T14:00:00.000Z",
    "location": "https://auth.example.com/scim/v2/Users/9c8b1a3e-5e2f-4b6e-9d8c-1f2a3b4c5d6e"
  }
}
```

Error cases:

| Condition                                                                                                          | Status | scimType       |
| ------------------------------------------------------------------------------------------------------------------ | ------ | -------------- |
| `emails[0].value` does not match `userName`                                                                        | `400`  | `invalidValue` |
| `emails` contains more than one entry                                                                              | `400`  | `invalidValue` |
| `userName` already in use by an active user                                                                        | `409`  | `uniqueness`   |
| Referenced `organizationId` is not in token scope                                                                  | `403`  | —              |
| Referenced organization does not exist                                                                             | `400`  | `invalidValue` |
| `organizationId` is not a valid UUID                                                                               | `400`  | `invalidValue` |
| Role name does not resolve to a known role                                                                         | `400`  | `invalidValue` |
| Both legacy `roles` and modern `organizationRoles` provided                                                        | `400`  | `invalidValue` |
| User's email domain is not allowed by the organization (see [Email-domain restriction](#email-domain-restriction)) | `400`  | `invalidValue` |

### `GET /scim/v2/Users/:id` — fetch a user

Returns the User resource, or `404` if the user does not exist or `403` if the user is not in the token's scope.

### `GET /scim/v2/Users` — list users

Supports filtering and pagination. See [Filtering and pagination](#filtering-and-pagination).

Filterable fields: `userName`, `externalId`.

`userName co "..."` (contains) is special-cased: it matches against the email **or** against the concatenated `"{firstName} {lastName}"`, so it can be used as a free-text search.

```http
GET /scim/v2/Users?filter=userName%20eq%20%22alex.morgan%40example.com%22&count=50
```

Response:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 1,
  "startIndex": 1,
  "itemsPerPage": 50,
  "Resources": [
    {
      /* ... User resource ... */
    }
  ]
}
```

### `PUT /scim/v2/Users/:id` — replace a user

Replaces the user's mutable attributes wholesale. Roles passed in the body fully replace the user's existing role assignments — anything not listed is removed.

```http
PUT /scim/v2/Users/9c8b1a3e-5e2f-4b6e-9d8c-1f2a3b4c5d6e
```

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:candid:scim:1.0:User"],
  "userName": "alex.morgan@example.com",
  "name": { "givenName": "Alex", "familyName": "Morgan" },
  "displayName": "Alex Morgan",
  "urn:candid:scim:1.0:User": {
    "organizationRoles": [
      {
        "organizationId": "11111111-1111-1111-1111-111111111111",
        "roles": ["Organization Admin"]
      },
      {
        "organizationId": "22222222-2222-2222-2222-222222222222",
        "roles": ["Organization Admin"]
      }
    ]
  }
}
```

### `PATCH /scim/v2/Users/:id` — partial update

See [PATCH operations](#patch-operations) for the general format. Allowed paths on User:

* `userName`
* `name.givenName`
* `name.familyName`
* `displayName`
* `roles`

Example — rename a user:

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

Example — change role assignments (legacy format):

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    {
      "op": "replace",
      "path": "roles",
      "value": [
        { "type": "PRIMARY_ORG", "value": "11111111-1111-1111-1111-111111111111" },
        { "type": "ROLE", "value": "Biller" }
      ]
    }
  ]
}
```

Notes:

* `op` is normalized to lowercase, so `Add` / `add` / `ADD` are equivalent.
* `add` and `replace` behave the same for scalar paths.
* `remove` is not supported on any User path.
* PATCH operations **without a `path`** are not supported — use explicit paths.

### `DELETE /scim/v2/Users/:id` — delete a user

Permanently deactivates the user.

* Marks the user record as deleted. Subsequent `GET /Users/:id` and `GET /Users` return `404` / omit the user.
* Deactivates all of the user's organization memberships.
* Removes the user's role tuples from the authorization model.
* The associated Auth0 account is hard-deleted.

Response: `204 No Content`.

The deletion is **permanent** — the user's UUID becomes unreachable forever. If you need to re-onboard the same person later, issue a new `POST /Users` with the same email; you'll receive a new UUID. Update your IdP's mapping accordingly.

### Deactivation

The `active` attribute is server-managed.

* It always returns `true` for users that exist (deleted users return `404`, not `active: false`).
* On `POST /Users` and `PUT /Users/:id`, sending `active` in the body is silently ignored.
* On `PATCH /Users/:id`, sending `path: "active"` is rejected with `400 invalidValue` (the path is not in the allowed list).

The only way to deactivate a user is `DELETE /scim/v2/Users/:id`. There is no soft-pause; deletion is the only off-switch.

### Email-domain restriction

An organization may be configured with a list of allowed email domains. When a user is added to such an organization (via create, replace, or patch with role assignments), the user's email domain must match one of the configured domains. Mismatches return:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "detail": "Email domain \"@example.com\" is not allowed for organization 11111111-1111-1111-1111-111111111111. Allowed: @acme-health.com, @acme.org.",
  "status": "400",
  "scimType": "invalidValue"
}
```

Organizations without any configured email domains accept users from any domain.

---

## Groups (Organizations)

In Candid Health, SCIM Groups represent **organizations**. Schema URI: `urn:ietf:params:scim:schemas:core:2.0:Group`.

Candid supports a two-level organization hierarchy:

* **Enterprise organizations** are top-level groups that can contain children. Flagged with `isEnterprise: true` on creation.
* **Child organizations** are singletons that reference an enterprise organization via `parentId`.

### Attributes

| Attribute                                       | Type    | Required  | Notes                                                                                                                                                                                                                                            |
| ----------------------------------------------- | ------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `displayName`                                   | string  | yes       | Human-readable organization name.                                                                                                                                                                                                                |
| `externalId`                                    | string  | no        | Stable identifier from your IdP. Globally unique when set.                                                                                                                                                                                       |
| `members`                                       | array   | read-only | Computed from User role assignments. Cannot be set via Group endpoints.                                                                                                                                                                          |
| `urn:candid:scim:1.0:Organization.parentId`     | string  | no        | UUID of the parent enterprise organization. Echoed back in responses.                                                                                                                                                                            |
| `urn:candid:scim:1.0:Organization.isEnterprise` | boolean | no        | Mark as an enterprise organization (can contain children). Default `false`. **Write-only**: accepted on create, not echoed in responses, and not modifiable via PUT/PATCH. Infer enterprise status from the presence of `isSso` in the response. |
| `urn:candid:scim:1.0:Organization.isSso`        | boolean | no        | If `true`, this enterprise organization uses SSO. Only meaningful when `isEnterprise: true`. Returned in responses **only for enterprise organizations** (omitted entirely for singleton orgs). Not modifiable via PUT/PATCH.                    |

### Candid Organization extension

```
urn:candid:scim:1.0:Organization
```

```json
{
  "parentId": "00000000-0000-0000-0000-000000000001",
  "isEnterprise": true,
  "isSso": false
}
```

### `POST /scim/v2/Groups` — create an organization

Create an enterprise organization:

```http
POST /scim/v2/Groups
```

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group", "urn:candid:scim:1.0:Organization"],
  "displayName": "Acme Health Network",
  "externalId": "acme-health-network",
  "urn:candid:scim:1.0:Organization": {
    "isEnterprise": true,
    "isSso": false
  }
}
```

Create a child organization under an enterprise organization:

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group", "urn:candid:scim:1.0:Organization"],
  "displayName": "Acme Cardiology Clinic",
  "externalId": "acme-cardiology",
  "urn:candid:scim:1.0:Organization": {
    "parentId": "00000000-0000-0000-0000-000000000001"
  }
}
```

Response: `201 Created`, `Location: /scim/v2/Groups/{id}`, body is the full Group resource. The extension object always contains `parentId` (or omits it for top-level orgs) and includes `isSso` only for enterprise organizations.

Child (singleton) organization response — `isSso` is omitted:

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group", "urn:candid:scim:1.0:Organization"],
  "id": "22222222-2222-2222-2222-222222222222",
  "externalId": "acme-cardiology",
  "displayName": "Acme Cardiology Clinic",
  "members": [],
  "urn:candid:scim:1.0:Organization": {
    "parentId": "00000000-0000-0000-0000-000000000001"
  },
  "meta": {
    "resourceType": "Group",
    "created": "2026-05-26T14:00:00.000Z",
    "lastModified": "2026-05-26T14:00:00.000Z",
    "location": "https://auth.example.com/scim/v2/Groups/22222222-2222-2222-2222-222222222222"
  }
}
```

Enterprise organization response — `isSso` is present:

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group", "urn:candid:scim:1.0:Organization"],
  "id": "00000000-0000-0000-0000-000000000001",
  "externalId": "acme-health-network",
  "displayName": "Acme Health Network",
  "members": [],
  "urn:candid:scim:1.0:Organization": {
    "isSso": false
  },
  "meta": {
    "resourceType": "Group",
    "created": "2026-05-26T14:00:00.000Z",
    "lastModified": "2026-05-26T14:00:00.000Z",
    "location": "https://auth.example.com/scim/v2/Groups/00000000-0000-0000-0000-000000000001"
  }
}
```

Error cases:

| Condition                                                        | Status | scimType       |
| ---------------------------------------------------------------- | ------ | -------------- |
| `parentId` is malformed                                          | `400`  | `invalidValue` |
| `parentId` refers to a non-enterprise organization or unknown ID | `400`  | `invalidValue` |
| `parentId` is outside the token's scope                          | `403`  | —              |
| `externalId` already in use                                      | `409`  | `uniqueness`   |

### `GET /scim/v2/Groups/:id` — fetch an organization

Returns the Group resource, including its current `members` array (computed from active User memberships). The `display` field is `"{givenName} {familyName}"`, not `displayName`.

```json
{
  "members": [
    {
      "value": "9c8b1a3e-5e2f-4b6e-9d8c-1f2a3b4c5d6e",
      "display": "Alex Morgan",
      "type": "User",
      "$ref": "https://auth.example.com/scim/v2/Users/9c8b1a3e-5e2f-4b6e-9d8c-1f2a3b4c5d6e"
    }
  ]
}
```

### `GET /scim/v2/Groups` — list organizations

Supports filtering and pagination. Filterable fields: `displayName`, `externalId`.

```http
GET /scim/v2/Groups?filter=displayName%20co%20%22Acme%22&startIndex=1&count=50
```

### `PUT /scim/v2/Groups/:id` — replace an organization

Updates `displayName`, `externalId`, and `parentId`. `isEnterprise` and `isSso` in the extension are silently ignored — these are fixed at creation time.

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group", "urn:candid:scim:1.0:Organization"],
  "displayName": "Acme Cardiology",
  "externalId": "acme-cardiology",
  "urn:candid:scim:1.0:Organization": {
    "parentId": "00000000-0000-0000-0000-000000000001"
  }
}
```

### `PATCH /scim/v2/Groups/:id` — partial update

Allowed paths:

* `displayName`
* `externalId`

`remove` is only supported for `externalId`.

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [{ "op": "replace", "path": "displayName", "value": "Acme Cardiology" }]
}
```

Operations on `members` are explicitly rejected — group membership is managed via User role assignments:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "detail": "Member operations are not supported. Assign users to organizations via the User roles attribute.",
  "status": "400",
  "scimType": "invalidValue"
}
```

### `DELETE /scim/v2/Groups/:id` — delete an organization

Soft-deletes the organization (`deactivatedAt` is set; reads return `404` afterward). If the organization is an enterprise organization, its enterprise marker is removed as well.

Returns `400` if the organization has any active children. Reassign or delete the children first.

Response: `204 No Content`.

---

## Role assignment

A user can have one or more **roles** in each organization they belong to. Roles control what actions the user is allowed to take. Permissions accumulate across all roles assigned to a user in a given organization.

Roles are assigned through the User resource on create (`POST /Users`), replace (`PUT /Users/:id`), and PATCH (`PATCH /Users/:id` with `path: "roles"`). There are two supported formats; pick one and use it consistently.

### Modern format (preferred)

The Candid User extension allows per-organization role assignments:

```json
{
  "urn:candid:scim:1.0:User": {
    "organizationRoles": [
      {
        "organizationId": "11111111-1111-1111-1111-111111111111",
        "roles": ["Biller"]
      },
      {
        "organizationId": "22222222-2222-2222-2222-222222222222",
        "roles": ["Viewer"]
      }
    ]
  }
}
```

### Legacy format

Use the standard SCIM `roles` attribute with four entry types:

```json
{
  "roles": [
    { "type": "PRIMARY_ORG", "value": "11111111-1111-1111-1111-111111111111" },
    {
      "type": "ADDITIONAL_ORGS",
      "value": "22222222-2222-2222-2222-222222222222,33333333-3333-3333-3333-333333333333"
    },
    { "type": "ROLE", "value": "Biller" },
    { "type": "ROLE", "value": "Viewer" },
    { "type": "ORGANIZATION_ADMIN", "value": "true" }
  ]
}
```

* `PRIMARY_ORG` and `ADDITIONAL_ORGS` enumerate the organization IDs the user belongs to. They are treated identically — both contribute organization memberships. Multiple `PRIMARY_ORG` entries are allowed.
* ⚠️ `ADDITIONAL_ORGS.value` is a **comma-separated list** of UUIDs in a single string, not a JSON array. This is the convention from our legacy provisioning system.
* `ROLE` entries are role names. Multiple `ROLE` entries are allowed; each role is applied to **every** organization in the user's set (there's no way to differentiate per-org assignments with the legacy format — use the modern format if you need that).
* ⚠️ `ORGANIZATION_ADMIN.value` is a **string** (`"true"`/`"false"`), not a boolean. Anything other than the literal string `"true"` is treated as `false`.

Because the legacy format applies every `ROLE` to every org uniformly, the modern format is strongly preferred for any new integration.

### Mixing formats

Sending both `organizationRoles` (modern) and `roles` (legacy) in the same request is rejected:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "detail": "Cannot mix legacy roles (PRIMARY_ORG/ADDITIONAL_ORGS/ROLE) with modern organizationRoles. Use one format or the other.",
  "status": "400",
  "scimType": "invalidValue"
}
```

### Role names

`roles` (modern) and `ROLE` (legacy) entries accept any name or alias listed in the role catalog below. Unknown role names — or known roles that are not on the SCIM-assignable allowlist — are rejected with `400 invalidValue` (detail: `Role "<name>" is not assignable via SCIM.`).

### Role catalog

The roles below can be assigned via SCIM. The same list is advertised as `canonicalValues` on the modern `organizationRoles[].roles` attribute in `GET /Schemas`, so SCIM clients that codegen from the schema can populate role pickers automatically.

Roles outside this list exist in Candid Health (e.g., internal Candid staff roles) but are intentionally not assignable through the SCIM API.

| Role name               | Notes                                                                                     |
| ----------------------- | ----------------------------------------------------------------------------------------- |
| `Organization Admin`    | Customer organization administrator. Typical choice for IT admins who manage other users. |
| `Biller`                | Customer-side biller.                                                                     |
| `Editor`                | Editor-level access.                                                                      |
| `Viewer`                | Read-only access.                                                                         |
| `Customer (API)`        | Programmatic / machine-to-machine integration role.                                       |
| `Front Office`          | Front-office staff.                                                                       |
| `Front Office Admin`    | Front-office administrator.                                                               |
| `Analytics`             | Analytics and reporting access.                                                           |
| `Docs`                  | Documentation access.                                                                     |
| `Remits App`            | Feature-flag role. Layer on top of a base role to grant access to the Remits application. |
| `Configurable Rules V2` | Feature-flag role. Layer on top of a base role to grant access to Configurable Rules V2.  |

Users can hold multiple roles in the same organization — for example, `Editor` plus `Remits App` to give an editor access to the Remits application. Permissions accumulate across all assigned roles.

> The legacy `{ "type": "ORGANIZATION_ADMIN", "value": "true" }` entry is preserved for backwards compatibility with the previous provisioning system but is **not** a SCIM-assignable role and is not listed in `canonicalValues`. New integrations should not rely on it; use `Organization Admin` instead.

### PUT vs. PATCH semantics

* `POST /Users` adds the user to all listed organizations with the listed roles.
* `PUT /Users/:id` and `PATCH /Users/:id` with `path: "roles"` **replace** the user's role assignments wholesale:
  * **Roles within an organization** are replaced — any role not listed for an org is removed from that org.
  * **Organization memberships** are reconciled — orgs newly listed are **added**, and orgs the user currently belongs to but that are omitted from the request are **removed** (the membership is deactivated, its OpenFGA role tuples are cleared, and the per-org removal is forwarded to candid-api).

To change roles within an existing org, list the org with its new role set. To grant access to a new org, add it to the list. To remove a user from a specific org, omit that org from a `PUT`/`PATCH` (`path: "roles"`) request. To deactivate the user across **all** orgs, use `DELETE /scim/v2/Users/:id`.

---

## PATCH operations

PATCH bodies follow RFC 7644 §3.5.2:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    { "op": "<add|remove|replace>", "path": "<attribute>", "value": <any> }
  ]
}
```

Notes:

* `Operations` (note the capital `O`) must contain at least one entry.
* `op` is case-insensitive (`add`, `Add`, `ADD` all accepted).
* A `path` is **required** on every operation; path-less PATCH is not supported.
* Unknown paths are rejected with `400`.
* `remove` is only meaningful for a small subset of paths (currently `externalId` on Group only).
* See the per-resource sections for the list of allowed paths.

---

## Filtering and pagination

List endpoints (`GET /Users`, `GET /Groups`) accept:

| Parameter    | Default | Notes                     |
| ------------ | ------- | ------------------------- |
| `filter`     | none    | See below.                |
| `startIndex` | `1`     | 1-based per SCIM spec.    |
| `count`      | `100`   | Page size. Maximum `100`. |

### Filter syntax

Only single-clause filters are supported. The grammar is:

```
<field> (eq|co) "<value>"
```

* `eq` — exact match.
* `co` — substring match (case-insensitive). For `/Users`, `userName co "..."` additionally searches `"{firstName} {lastName}"`.

Supported fields:

* `/Users`: `userName`, `externalId`
* `/Groups`: `displayName`, `externalId`

Examples:

```http
GET /scim/v2/Users?filter=externalId%20eq%20%22auth0%7C65f0c1234567890abcdef111%22
GET /scim/v2/Groups?filter=displayName%20co%20%22Acme%22
```

Unsupported syntax (logical operators `and`/`or`, parenthesized expressions, other operators like `sw`/`ew`/`gt`) returns `400 invalidFilter`.

### Response format

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 137,
  "startIndex": 1,
  "itemsPerPage": 50,
  "Resources": [
    /* ... */
  ]
}
```

---

## Errors

All errors follow RFC 7644 §3.12:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "detail": "<human-readable description>",
  "status": "<HTTP status as string>",
  "scimType": "<SCIM error subtype, when applicable>"
}
```

The `Content-Type` of error responses is always `application/scim+json`.

| HTTP status | `scimType`       | When                                                                      |
| ----------- | ---------------- | ------------------------------------------------------------------------- |
| `400`       | `invalidValue`   | Request body or an attribute value failed validation.                     |
| `400`       | `invalidFilter`  | Filter syntax is unsupported or malformed.                                |
| `401`       | —                | Missing, invalid, or expired bearer token.                                |
| `403`       | —                | Resource is outside the token's organization scope.                       |
| `404`       | —                | Resource does not exist (or has been deleted).                            |
| `409`       | `uniqueness`     | Email (User) or externalId (Group) already in use.                        |
| `500`       | —                | Unhandled server error.                                                   |
| `501`       | `notImplemented` | Operation is not yet supported — see [Future features](#future-features). |

---

## IdP compatibility notes

Common provisioning systems (Okta, Microsoft Entra/Azure AD, OneLogin, etc.) emit SCIM payloads that don't always match our supported surface. The most frequent friction points:

### Deactivation via `active: false`

Most IdPs send `PATCH /Users/:id` with `{ "op": "replace", "path": "active", "value": false }` when a user is suspended. Our API rejects this with `400 invalidValue` because `active` is not in the allowed PATCH paths.

Equivalent on PUT (sending `active: false` in a full resource body) is silently ignored — the user stays active.

To deprovision a user, send `DELETE /scim/v2/Users/:id`. If your IdP cannot be configured to issue `DELETE` on suspend, see your IdP's documentation for "delete on unassign" or an equivalent setting.

### Path-less PATCH

Some IdPs (notably Microsoft Entra ID) issue PATCH operations without a `path`, e.g.:

```json
{
  "Operations": [{ "op": "replace", "value": { "name": { "givenName": "Alex" } } }]
}
```

This is rejected with `400`:

```
Patch operations without a path are not supported. Use explicit paths (e.g., "active", "userName").
```

You must rewrite these to use explicit paths, one operation per attribute:

```json
{
  "Operations": [{ "op": "replace", "path": "name.givenName", "value": "Alex" }]
}
```

### PATCH filter expressions in paths

Bracket-filter PATCH paths like `emails[type eq "work"].value` are rejected because the path doesn't match an allowed value. Use the bare attribute path (`userName` to change email).

### Group membership management

Many IdPs assign users to groups by issuing `PATCH /Groups/:id` with operations on `members`. We reject this with `400`:

```
Member operations are not supported. Assign users to organizations via the User roles attribute.
```

Group membership in our model is **derived** from User role assignments, not directly managed. Configure your IdP to manage organization access on the User side, using the modern [`organizationRoles`](#role-assignment) extension (preferred) or the legacy `roles` attribute.

### Unsupported standard extensions

The SCIM enterprise extension (`urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`) and any other custom attributes your IdP sends are silently stripped — no error, but no effect. Use the Candid extensions (`urn:candid:scim:1.0:User`, `urn:candid:scim:1.0:Organization`) for any custom data the API can act on.

### Unsupported filter syntax

Compound filters (`and`, `or`, parenthesization) and operators beyond `eq`/`co` (`sw`, `ew`, `gt`, `pr`, etc.) return `400 invalidFilter`. If your IdP issues these for sync, you may need to disable filtering and accept a full sync, or replicate the filtering on your side.

---

## Idempotency

Retries are common in IdP provisioning. The behavior of each verb under retry:

| Verb           | Idempotent?     | Notes                                                                                                                                                                                                                                          |
| -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`          | yes             | Safe to retry indefinitely.                                                                                                                                                                                                                    |
| `POST /Users`  | no              | A successful create whose response is lost will cause the retry to fail with `409 uniqueness` on email. Handle `409` by issuing `GET /Users?filter=userName eq "..."` to recover the existing resource.                                        |
| `POST /Groups` | no              | Same pattern: retry on lost response → `409` on `externalId` (when set). If `externalId` is not set, the retry will create a duplicate organization with a new UUID. Always set `externalId` on POST `/Groups` if you intend to be retry-safe. |
| `PUT`          | yes             | Full replacement is naturally idempotent.                                                                                                                                                                                                      |
| `PATCH`        | depends         | `replace` is idempotent; repeated `add` of the same value is a no-op; `remove` of an already-cleared value is a no-op.                                                                                                                         |
| `DELETE`       | effectively yes | A retry returns `404` (the user/group no longer exists), which your client should treat as success-equivalent.                                                                                                                                 |

---

## Future features

The following capabilities are not yet implemented. Requests that depend on them will return errors as described.

### Per-organization role differentiation

The modern `urn:candid:scim:1.0:User.organizationRoles` extension accepts a `roles` array per organization, but all organizations assigned in a single request must currently resolve to the **same set of role assignments**. The model allows distinct per-organization roles in the wire format today, and per-organization role differentiation is planned but not yet active.

### Bulk operations

`POST /scim/v2/Bulk` is not supported. The `ServiceProviderConfig` advertises `bulk.supported: false`. Issue one request per resource.

### Sorting

The `sortBy` and `sortOrder` parameters on list endpoints are not implemented. `ServiceProviderConfig` advertises `sort.supported: false`. The current default ordering on list endpoints is undefined; clients should not rely on a specific order.

### Password changes

Candid Health does not store user passwords — authentication is delegated to Auth0 (or to the customer's IdP for SSO organizations). `PUT /scim/v2/Me/password` is not supported and `ServiceProviderConfig` advertises `changePassword.supported: false`.

### ETag / optimistic concurrency

Resource versioning via `ETag` / `If-Match` is not implemented. `ServiceProviderConfig` advertises `etag.supported: false`. Concurrent writes are last-write-wins.

### Compound filter expressions

Only single-clause `eq` and `co` filters are supported. Compound expressions (`and`, `or`, parenthesization) and additional operators (`sw`, `ew`, `gt`, `ge`, `lt`, `le`, `pr`, `ne`) return `400 invalidFilter`.