> ## Documentation Index
> Fetch the complete documentation index at: https://docs.opal.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Connector API Spec

Use this API spec to build your custom connector after you've generated boilerplate code following the steps in [Create your own connector](https://docs.opal.dev/docs/how-to-create-your-own-connector.md). You can also reference the [Datadog custom connector](https://github.com/opalsecurity/opal-datadog-connector) as you build your implementation.

# Endpoints

## GET `/status`

Checks the status of the connector. This endpoint is mainly used for successfully creating the app in Opal and verifying that the connector is properly configured.

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list resources for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |

**Response params (200)**

No body is required in the response for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See [Error Codes](#error-codes) for details. |

## GET `/resources`

Returns a list of all resources for the app in question. This endpoint is called after you've [imported resources](https://docs.opal.dev/docs/import-and-configure-resources-and-groups.md#manual-import) to the app, so you may not see it being called when you first create your app.

To distinguish resources pulled from multiple sources, you can include the type in the identifier, then base64-encode the ID so it can be properly parsed—e.g., `base64(resource-type:1)`.

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list resources for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints. |
| cursor | string | For pagination. Empty string on the first call, then set to the value provided via `next_cursor`. |
| parent\_id | string | **Optional**: Used for nested resources. If you enable **Nested Resources** in the Opal connector form, this endpoint finds the resource specified by `parent_id` and returns its immediate children. See [Create your own connector](https://docs.opal.dev/docs/how-to-create-your-own-connector.md) for more information. |

**Response params (200)**

| param | type | description |
| - | - | - |
| resources | array of objects | List of the resource objects. See below for what each resource should include. |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all resources have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Resource object**

| field | type | description |
| - | - | - |
| id | string | The id of the resource that uniquely identifies it in your system. It can be at most 65535 characters. |
| name | string | The name of the resource. |
| description | string | The description of the resource. |

**Example response**

```json json theme={null}
{
  "next_cursor": "gjroieapghnfagjfdgpadshfasd",
  "resources": [
    {
      "id": "1",
      "name": "Gooli Metadata",
      "description": "Access to Gooli customer metadata"
    },
    {
      "id": "2",
      "name": "Eviato Metadata",
      "description": "Access to Eviato customer metadata",
      "can_have_usage_data": true
    },
    {
      "id": "3",
      "name": "Moolybib Metadata",
      "description": "Access to Moolybib customer metadata"
    }
  ]
}
```

## GET `/resources/{resource_id}`

Get a specific resource by its ID.

**Path params**

| param | type | description |
| - | - | - |
| resource\_id | string | The id of the resource. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list resources for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints. |

**Response params (200)**

| field | type | description |
| - | - | - |
| resource | object | Resource object |

**Resource object**

| field | type | description |
| - | - | - |
| id | string | The id of the resource that uniquely identifies it in your system. It should match the ID (`<resource_id>`) of the request. |
| name | string | The name of the resource. |
| description | string | The description of the resource. |
| can\_have\_usage\_data | boolean | **Optional**. This property is used to enable ingesting usage events for a resource to enable LPPM for custom connections. See [the events endpoint](https://docs.opal.dev/docs/api-spec.md#get-events) for more information. `false` if unspecified. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Example response**

```json json theme={null}
# GET /resources/1?app_id=app_unique_id
{
  "resource": {
      "id": "1",
      "name": "Gooli Metadata",
      "description": "Access to Gooli customer metadata"
   }
}
```

## GET `/resources/{resource_id}/access_levels`

> [!NOTE]
>
> If your resource doesn't require any access level, this endpoint can return
> `"access_levels": []`.

Returns all available access levels for a resource, paginated. Access levels are exposed in the Opal dashboard and to end users as roles, which users select when they request access to the resource.

**Path params**

| param | type | description |
| - | - | - |
| resource\_id | string | The id of the resource to retrieve the access levels of. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list resources for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then set to the value provided via next\_cursor |

**Response params (200)**

| param | type | description |
| - | - | - |
| access\_levels | array of objects | List of the access levels objects. See below for what each object should include. If your resource doesn't require any access level, it can return `[]`. |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all resources have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Access level object**

| field | type | description |
| - | - | - |
| id | string | ID of the access level to grant to this user. |
| name | string | The user-facing name of the access level. |

**Example response**

```json json theme={null}
{
  "next_cursor": "gjroieapghnfagjfdgpadshfasd",
  "access_levels": [
    {
      "id": "1",
      "name": "Admin"
    },
    {
      "id": "2",
      "name": "Read-Only Admin"
    },
    {
      "id": "3",
      "name": "Guest"
    }
  ]
}
```

## GET `/resources/{resource_id}/users`

Returns the users that currently have access to the provided resource.

**Path params**

| param | type | description |
| - | - | - |
| resource\_id | string | The id of the resource you’re trying to list the details of, as returned as `id` in the `/resources/<resource_id>` endpoint. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then passed values provided via next\_cursor |

**Response params (200)**

| param | type | description |
| - | - | - |
| users | array of objects | List of the resource user objects. See below for what each resource should include |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all resources have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message to expose in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**ResourceUser object**

| field | type | description |
| - | - | - |
| user\_id | string | The user identifier used in the remote system, this will be provided back to you when making access changes. |
| email | string | The email of the user. This will be used on our end to correlate it with Opal users. Although not required at the moment, we'd like implementers to include this value as well. |
| access\_level | object | The access level granted to the user. If omitted, the default access level remote ID value (empty string) is used. |

**Example response**

```Text JSON theme={null}
{
  "next_cursor": "bpaubmkeospb",
  "users": [
    {
      "email": "[email protected]",
      "user_id": "1",
      "access_level": {
        "id": "1",
        "name": "Admin"
      }
    },
    {
      "email": "[email protected]",
      "user_id": "2",
      "access_level": {
        "id": "2",
        "name": "Read-Only Admin"
      }
    }
  ]
}
```

## POST `/resources/{resource_id}/users`

Adds a user to the access list of the specified resource.

**Path params**

| param | type | description |
| - | - | - |
| resource\_id | string | The id of the resource you’re trying to add the user to |

**Body params (JSON encoded)**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| user\_id | string | The id of the user to add to the resource |
| access\_level\_id | string (optional) | The ID of the access level to assign to the user. |
| custom\_fields | array (optional) | Custom fields from the access request that initiated this provisioning. Only present when provisioning was triggered by a user access request. Each object contains `field_name` (string), `field_type` (string: `SHORT_TEXT`, `LONG_TEXT`, `BOOLEAN`, `MULTI_CHOICE`), and `field_value` (string). |

**Example request**

```Text JSON theme={null}
{
	"app_id": "datadog-production",
  "user_id": "568d0cd1-284d-4e82-a6c7-9c98b5e51a65",
  "access_level_id": "12f5ef35-5de6-4224-a006-7fe4c20db5c6",
  "custom_fields": [
    {
      "field_name": "environment",
      "field_type": "SHORT_TEXT",
      "field_value": "production"
    }
  ]
}
```

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## DELETE `/resources/{resource_id}/users/{user_id}`

Remove a user from the access list of the specified resource.

**Path params**

| param | type | description |
| - | - | - |
| resource\_id | string | The id of the resource to be edited. |
| user\_id | string | The id of the user to be removed. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| access\_level\_id | string (optional) | The ID of the access level associated to the user for this resource. |

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## GET `/groups`

Returns a list of all groups for the app in question. The `/groups` endpoints are only called if **Connector Groups** is enabled in the [custom app form](https://docs.opal.dev/docs/how-to-create-your-own-connector.md#4-create-a-custom-app).

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list groups for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then set to the value provided via next\_cursor |

**Response params (200)**

| param | type | description |
| - | - | - |
| groups | array of objects | List of the group objects. See below for what each group should include |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all groups have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Group object**

| field | type | description |
| - | - | - |
| id | string | The id of the group that uniquely identifies it in your system. |
| name | string | The name of the group |
| description | string | The description of the group |

**Example response**

```json json theme={null}
{
  "next_cursor": "gjroieapghnfagjfdgpadshfasd",
  "groups": [
    {
      "id": "1",
      "name": "Eng team",
      "description": "Eng team users"
    },
    {
      "id": "2",
      "name": "Finance team (North America)",
      "description": "Users in the finance team in NA"
    }
  ]
}
```

## GET `/groups/{group_id}`

Get a specific group by its id.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The id of the resource |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list resources for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |

**Response params (200)**

| field | type | description |
| - | - | - |
| group | object | A Group object. |

**Group object**

| field | type | description |
| - | - | - |
| id | string | The id of the resource that uniquely identifies it in your system. |
| name | string | The name of the resource |
| description | string | The description of the resource |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Example response**

```json json theme={null}
# GET /groups/1?app_id=app_unique_id
{
  "group": {
      "id": "1",
      "name": "Eng team",
      "description": "Your friendly eng team."
   }
}
```

## GET `/groups/{group_id}/users`

Returns the users that currently belong to the provided group.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The id of the group you’re trying to list the details of |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then passed values provided via next\_cursor |

**Response params (200)**

| param | type | description |
| - | - | - |
| users | array of objects | List of the group user objects. See below for what each group should include |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all resources have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message to expose in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**GroupUser object**

| field | type | description |
| - | - | - |
| user\_id | string | The user identifier used in the remote system, this will be provided back to you when making access changes. |
| email | string | The email of the user. This will be used on our end to correlate it with Opal users. Although not required at the moment, we'd like implementers to include this value as well. |

**Example response**

```Text JSON theme={null}
{
  "next_cursor": "bpaubmkeospb",
  "users": [
    {
      "email": "[email protected]",
      "user_id": "1",
    },
    {
      "email": "[email protected]",
      "user_id": "2",
    }
  ]
}
```

## POST `/groups/{group_id}/users`

Adds a user to the access list of the specified group.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The id of the group you’re trying to add the user to |

**Body params (JSON encoded)**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| user\_id | string | The id of the user to add to the group |
| custom\_fields | array (optional) | Custom fields from the access request that initiated this provisioning. Only present when provisioning was triggered by a user access request. Each object contains `field_name` (string), `field_type` (string: `SHORT_TEXT`, `LONG_TEXT`, `BOOLEAN`, `MULTI_CHOICE`), and `field_value` (string). |

**Example request**

```Text JSON theme={null}
{
	"app_id": "datadog-production",
  "user_id": "568d0cd1-284d-4e82-a6c7-9c98b5e51a65",
  "custom_fields": [
    {
      "field_name": "environment",
      "field_type": "SHORT_TEXT",
      "field_value": "production"
    }
  ]
}
```

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## DELETE `/groups/{group_id}/users/{user_id}`

Remove a user from the access list of the specified group.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The id of the group to be edited. |
| user\_id | string | The id of the user to be removed. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## GET `/groups/{group_id}/resources`

Returns the resources that currently belong to the provided group.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The id of the group you’re trying to list the details of |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then passed values provided via next\_cursor |

**Response params (200)**

| param | type | description |
| - | - | - |
| resources | array of objects | List of the group resource objects. See below for what each group should include |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all resources have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message to expose in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**GroupResource object**

| field | type | description |
| - | - | - |
| resource\_id | string | The resource identifier used in the remote system, this will be provided back to you when making access changes. |
| access\_level | object | The access level the group has access to the resource with. |

**Example response**

```Text JSON theme={null}
{
  "next_cursor": "bpaubmkeospb",
  "resources": [
    {
      "resource_id": "1",
      "access_level": {
        "id": "1",
        "name": "Admin"
      }
    },
    {
      "resource_id": "2",
      "access_level": {
        "id": "2",
        "name": "Read-Only Admin"
      }
    }
  ]
}
```

## POST `/groups/{group_id}/resources`

Adds a resource to the access list of the specified group.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The id of the group you’re trying to add the resource to |

**Body params (JSON encoded)**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| resource\_id | string | The id of the resource to add to the group |
| access\_level\_id | string (optional) | The ID of the access level to assign to the group to the resource. |

**Example request**

```Text JSON theme={null}
{
	"app_id": "datadog-production",
  "resource_id": "568d0cd1-284d-4e82-a6c7-9c98b5e51a65",
  "access_level_id": "12f5ef35-5de6-4224-a006-7fe4c20db5c6"
}
```

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## DELETE `/groups/{group_id}/resources/{resource_id}`

Remove a resource from the access list of the specified group.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The id of the group to be edited. |
| resource\_id | string | The id of the resource to be removed. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The app id specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints |
| access\_level\_id | string (optional) | The ID of the access level associated to the group for this resource. |

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## GET `/groups/{group_id}/member-groups`

Returns member groups for the specified group. Required if you enable **Nested Groups** when you create the custom app.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The ID of the parent group. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list users for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then set to the value provided via next\_cursor |

**Response params (200)**

| param | type | description |
| - | - | - |
| groups | array of objects | List of the member groups. See the following **Member group object** for fields. |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all resources have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Member group object**

| field | type | description |
| - | - | - |
| group\_id | string | The id of the member group that uniquely identifies it in your system. |
| name | string | The name of the group. |
| description | string | The description of the group. |

## POST `/groups/{group_id}/member-groups`

Add a member group to the specified group. Required if you enable **Nested Groups** when you create the custom app.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The ID of the parent group. |

**Body params (JSON encoded)**

| param | type | description |
| - | - | - |
| group\_id | string | The ID of the member group to add. |
| app\_id | string | The ID of the custom app. |

**Example request**

```Text JSON theme={null}
{
	"group_id": "member-group-id-123",
  "app_id": "unique-app-id-456",
}
```

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## DELETE `/groups/{group_id}/member-groups/{member_group_id}`

Remove a member group from the specified group. Required if you enable **Nested Groups** when you create the custom app.

**Path params**

| param | type | description |
| - | - | - |
| group\_id | string | The ID of the parent group. |
| member\_group\_id | string | The ID of the member group to remove. |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The identifier of your custom app. |

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

## GET `/users`

Returns a list of users for your custom connector app.

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to list users for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then set to the value provided via next\_cursor |

**Response params (200)**

| param | type | description |
| - | - | - |
| users | array of objects | List of the user objects. See below for what each user should include |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all resources have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**User object**

| field | type | description |
| - | - | - |
| id | string | The id of the user that uniquely identifies it in your system. |
| email | string | The email of the user, this is required to associate the user in Opal. |
| status | string | Optional. The user's lifecycle status: `ACTIVE`, `SUSPENDED`, `DEPROVISIONED` or `DELETED`. Omit it to give no opinion on status. Any other value fails the sync. Only read for upstream directories. |
| manager\_email | string | Optional. The email of the user's manager. Omit it to leave the manager unchanged. Only read for upstream directories. |
| attributes | UserAttributes | Optional. Standard profile fields: `first_name`, `last_name`, `title`, `team`, `profile_url` and `secondary_emails`. Only read for upstream directories. |
| custom\_attributes | object | Optional. Any other attributes, as a map from attribute name to an array of string values. Opal admins map them to tags, team or email. Only read for upstream directories. |

### Using your connector as an upstream directory

A custom connector can be the source of truth for your users, the same way an HR system or IDP is. To do this, enable **Upstream Directory** on the app in Opal, then connect it under **Settings > Connect IDP & HR as Upstream Directory**. Opal then reads `status`, `manager_email`, `attributes` and `custom_attributes` from `GET /users` and uses them to drive user lifecycle, manager approvals and attribute-based tags.

When your connector is an upstream directory, `GET /users` must return **every** user in scope on every sync, including suspended and deprovisioned users. Opal treats a user who is missing from the response as removed from your system and offboards them.

The `email` inside `attributes` is never read. The top-level `email` is the user's primary email, unless an admin maps a `custom_attributes` key to email in Opal.

**Example**

```Text JSON theme={null}
{
  "next_cursor": "kjhiufewnoi",
  "users": [
    {
      "email": "[email protected]",
      "id": "6bac7de4-08d7-4437-8397-75f44b6f76ae"
    },
    {
      "email": "[email protected]",
      "id": "c1623a9c-cfb8-4c50-bd89-ea05bf597d60"
    },
    {
      "email": "[email protected]",
      "id": "968bf191-ba81-4fa0-a5a9-aaff71655fbd"
    },
    {
      "email": "[email protected]",
      "id": "d67d4184-6cdf-4c43-9d65-698997bd0c52"
    }
  ]
}
```

```Text JSON (upstream directory) theme={null}
{
  "next_cursor": "",
  "users": [
    {
      "id": "6bac7de4-08d7-4437-8397-75f44b6f76ae",
      "email": "jordan.hayes@example.com",
      "status": "ACTIVE",
      "manager_email": "sam.rivera@example.com",
      "attributes": {
        "first_name": "Jordan",
        "last_name": "Hayes",
        "title": "Software Engineer",
        "team": "Platform"
      },
      "custom_attributes": {
        "work_location": ["Remote"],
        "employee_type": ["Full-time"]
      }
    }
  ]
}
```

## GET `/users/{user_id}`

Optional. Returns one user, in the same shape as an entry from `GET /users`. When your connector is an upstream directory, Opal calls this to refresh a single user as soon as it learns of a change, instead of waiting for the next full `GET /users` sync. If you don't implement it, Opal picks up changes on the next full sync.

Return a 404 with an error body only when the user doesn't exist in your system. Opal ignores a 404 without that body, so a connector that doesn't implement this endpoint is never read as having removed a user.

**Path params**

| param | type | description |
| - | - | - |
| user\_id | string | The id of the user, as returned by `GET /users` |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The Opal app ID to fetch the user for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |

**Response params (200)**

| param | type | description |
| - | - | - |
| user | object | The user. See the User object under `GET /users`. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Example**

```Text JSON theme={null}
{
  "user": {
    "id": "6bac7de4-08d7-4437-8397-75f44b6f76ae",
    "email": "jordan.hayes@example.com",
    "status": "DEPROVISIONED"
  }
}
```

## POST `/users`

This endpoint enables users to be provisioned into a custom connector app. This functionality is only available if user provisioning has been enabled for the connection.

**Body params (JSON encoded)**

| field | type | description |
| - | - | - |
| user\_id | string | The Opal User ID being created in your end system |
| attributes | UserAttributes | The attributes of the user to create in your custom connector app |
| manager | UserAttributes | The attributes of the manager of the user |
| user\_tags | Array | The tags of the user that were ingested by Opal |

**UserAttributes object**

| field | type | description |
| - | - | - |
| email | string | The email of the user |
| first\_name | string | The first name of the user |
| last\_name | string | The last name of the user |
| title | string | The title of the user, if available |
| team | string | The team the user is a part of, if available |
| profile\_url | string | A URL to the user's profile photo |
| secondary\_emails | Array | The secondary emails of the user, if available |

**UserTag object**

| field | type | description |
| - | - | - |
| key | string | The key of the tag in Opal |
| value | string | The value of the tag in Opal |
| tag\_id | string | The Opal ID of the tag |
| connection\_id | string | The Opal ID of the connection where this tag was sourced from |

**Response params (200)**

| param | type | description |
| - | - | - |
| remote\_user\_id | string | The ID of the user in the custom connector app. This must be returned by your implementation and will be used by Opal to refer to this user in the future. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI |
| code | integer | The error code that describes the error. See Error Codes for details |

## PUT `/users/{user_id}`

This endpoint updates a user's identity in your custom connector app. Opal only calls this endpoint when **Update users in app** is enabled for the connection. The request body contains the same Opal-managed identity fields as create/delete—replace those fields in your end system and leave any end-system-only fields untouched.

**Path params**

| param | type | description |
| - | - | - |
| user\_id | string | The remote identifier of the user, as returned by createUser. |

**Body params (JSON encoded)**

| field | type | description |
| - | - | - |
| opal\_uuid | string | The Opal-internal UUID for this user |
| attributes | UserAttributes | The attributes of the user being updated |
| manager | UserAttributes | The attributes of the manager of the user |
| user\_tags | Array | The tags of the user that were ingested by Opal |

**UserAttributes object**

| field | type | description |
| - | - | - |
| email | string | The email of the user |
| first\_name | string | The first name of the user |
| last\_name | string | The last name of the user |
| title | string | The title of the user, if available |
| team | string | The team the user is a part of, if available |
| profile\_url | string | A URL to the user's profile photo |
| secondary\_emails | Array | The secondary emails of the user, if available |

**UserTag object**

| field | type | description |
| - | - | - |
| key | string | The key of the tag in Opal |
| value | string | The value of the tag in Opal |
| tag\_id | string | The Opal ID of the tag |
| connection\_id | string | The Opal ID of the connection where this tag was sourced from |

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (404)**

Return this status when the remote user does not exist in your end system. See [Error Codes](#error-codes) for the response body shape.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details |

## DELETE `/users/{user_id}`

This endpoint deprovisions user accounts in a custom connector application. This functionality is only available if user deprovisioning has been enabled for the connection.

Return `404` if the user has already been removed — Opal treats this as an idempotent success and does not surface it as an error.

**Path params**

| param | type | description |
| - | - | - |
| user\_id | string | The ID of the user to be removed from the application. |
| attributes | UserAttributes | The attributes of the user to create in your custom connector app |
| manager | UserAttributes | The attributes of the manager of the user |
| user\_tags | Array | The tags of the user that were ingested by Opal |

**UserAttributes object**

| field | type | description |
| - | - | - |
| email | string | The email of the user |
| first\_name | string | The first name of the user |
| last\_name | string | The last name of the user |
| title | string | The title of the user, if available |
| team | string | The team the user is a part of, if available |
| profile\_url | string | A URL to the user's profile photo |
| secondary\_emails | Array | The secondary emails of the user, if available |

**UserTag object**

| field | type | description |
| - | - | - |
| key | string | The key of the tag in Opal |
| value | string | The value of the tag in Opal |
| tag\_id | string | The Opal ID of the tag |
| connection\_id | string | The Opal ID of the connection where this tag was sourced from |

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | The app ID specified by the end user during connection process. This can be used in your end system to distinguish apps if you are supporting multiple apps via the same set of endpoints. |

**Response params (200)**

No body is required as part of this endpoint for status code 200.

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details |

## GET `/events`

This endpoint is only called if **Connector Event Ingestion** is enabled in the [custom app form](https://docs.opal.dev/docs/how-to-create-your-own-connector.md#4-create-a-custom-app).

**Query params**

| param | type | description |
| - | - | - |
| app\_id | string | **Required**. The Opal app ID to list events for. This allows your service to distinguish apps if it’s supporting multiple apps via the same set of endpoints |
| cursor | string | For pagination. Empty string on the first call, then set to the value provided via next\_cursor |
| start | string | Return events starting from this date. Use the format `YYYY-MM-DDT00:00:00Z`. |
| end | string | Return events ending before this date. Use the format `YYYY-MM-DDT00:00:00Z`. |

**Response params (200)**

| param | type | description |
| - | - | - |
| events | array of objects | List of the event objects. See below for what each event should include |
| next\_cursor | string | The cursor that should be used for the next call. If cursor is an empty string, it is assumed that all evets have been fetched. |

**Response params (Error)**

| param | type | description |
| - | - | - |
| message | string | An error message that will be exposed in the Opal UI. |
| code | integer | The error code that describes the error. See Error Codes for details. |

**Event object**

| field | type | description |
| - | - | - |
| event\_id | string | The id of the event that uniquely identifies it in your system. |
| event\_type | OneOf: `login.success` | The type of the event. |
| [occurred\_at](https://www.rfc-editor.org/rfc/rfc3339) | RFC3339 datetime string | The date and time at which the event occurred. Return as a string using the following format: `{year}-{month}-{day}T{hour}:{minute}:{second}.{microsecond}%z`. |
| actor\_user\_identifier | ActorUserIdentifier object | An ActorUserIdentifier object uniquely identifying the user that performed this event. See below for what this object should include. |
| event\_content | OneOf: `EventContentLoginSuccess `object | The content of this event, which is specific to the event type. See below for what each event content should include. |

**ActorUserIdentifier object**

| field | type | description |
| - | - | - |
| user\_id | string | The id of the user that uniquely identifies it in your system. |
| user\_email | string | The email of the user, this is required to associate the user in Opal. |

**EventContentLoginSuccess object**

| field | type | description |
| - | - | - |
| resource\_id | string | The id of the resource that the user logged in to. |

**Example**

```Text JSON theme={null}
{
  "next_cursor": "kjhiufewnoi",
  "events": [
    {
        "event_id": "1",
        "event_type": "login.success",
        "occurred_at": "2025-01-15T00:58:33.967628+0000",
        "actor_user_identifier": {
            "user_id": "6bac7de4-08d7-4437-8397-75f44b6f76ae",
            "user_email": "[email protected]",
        },
        "event_content": {"resource_id": "568d0cd1-284d-4e82-a6c7-9c98b5e51a65"},
    },
    {
        "event_id": "2",
        "event_type": "login.success",
        "occurred_at": "2025-01-15T00:58:33.967628+0000",
        "actor_user_identifier": {
            "user_id": "[email protected]",
            "user_email": "c1623a9c-cfb8-4c50-bd89-ea05bf597d60",
        },
        "event_content": {"resource_id": "568d0cd1-284d-4e82-a6c7-9c98b5e51a65"},
    },
    {
        "event_id": "3",
        "event_type": "login.success",
        "occurred_at": "2025-01-15T00:58:33.967628+0000",
        "actor_user_identifier": {
            "user_id": "d67d4184-6cdf-4c43-9d65-698997bd0c52",
            "user_email": "[email protected]",
        },
        "event_content": {"resource_id": "568d0cd1-284d-4e82-a6c7-9c98b5e51a65"},
    },
  ]
}
```

# Error codes

All error responses use the following object:

| param | type | description |
| - | - | - |
| message | string | An error message to expose in the Opal UI. |
| code | integer | The error code describing the error. See the table below. |

Return exactly these two fields with `Content-Type: application/json`. Extra or missing fields, or a non-JSON content type, mean Opal can't decode the body — the HTTP status is still honored, but your `message` is not shown in the UI.

## Success and failure

Opal uses the HTTP status code to decide whether a call succeeded. Any `2xx` is treated as a success — a body field like `code` or `status` is never read as a failure signal. To report a failure, return a non-`2xx` HTTP status with the error object above.

Opal does decode the `2xx` response body on the data endpoints: every `GET` except `/status`, plus `POST /users`. On those, the response must use `Content-Type: application/json` and parse cleanly against the schema. For the `GET`s, a malformed or empty body is recorded as a failure. All other endpoints ignore the `2xx` body entirely.

`POST /users` must return `{"remote_user_id": "<id>"}` with the user's identifier in your system — Opal stores this to correlate with the Opal user on later calls. An empty body is accepted as success but leaves Opal without the remote ID; a body present but not matching the schema (missing `remote_user_id`, extra fields, wrong types) fails the call.

## Status codes and retries

| Status code | Meaning | Retried immediately? |
| - | - | - |
| 2xx | Response successful. | No |
| 401 | Unauthorized (e.g. invalid request signature). | No |
| 404 | Entity not found (eg. resource, user, access level). | No |
| 429 | Rate limit exceeded. | Yes |
| 500 | Unexpected server error. Any `5xx` other than `501` behaves the same way. | Yes |
| 501 | Not implemented. | No |
| Other 4xx | Other request errors (e.g. `400`, `403`, `405`, `409`, `422`). | No |

Opal retries `429`, `5xx` (except `501`), and network errors up to **3 times** (4 attempts in total) with exponential backoff: 1s, then 2s, then 4s. The `Retry-After` header is honored on `429` and `503` — see [Rate Limiting](#rate-limiting) below.

That column covers the HTTP request only. A call that fails without an immediate retry can still be re-attempted later: a provisioning push is re-sent if the request is approved again, and a failing deprovision is re-attempted on subsequent syncs before Opal gives up on it. Make your endpoints idempotent — no status code currently tells Opal that a failure is permanent and should not be re-attempted.

Your `message` is shown in the UI for statuses that aren't retried immediately. For `429` and `5xx`, once the retry budget is spent the body is dropped and the UI shows a generic retry-exhaustion message instead (containing `giving up after N attempt(s): status N`).

## Body `code` values

Opal reads the body `code` to classify the failure, but only on the error responses it does not retry — the statuses marked **No** in [Status codes and retries](#status-codes-and-retries) above. On a retried status the body is dropped once the retry budget is spent, so the `code` never reaches Opal.

For a non-retried error, the behavior depends on the endpoint:

* **Group, resource, and membership writes** (`POST`/`DELETE` on `/groups/{group_id}/users`, `/groups/{group_id}/resources`, `/groups/{group_id}/member-groups`, and `/resources/{resource_id}/users`): `code: 404` is recorded as permanent "resource not found". Any other value is a generic remote error.
* **Every `GET`**: `code: 401` is recorded as "not authorized to query resource", and `code: 404` as "resource not found". Any other value is a generic remote error. `GET /resources/{resource_id}` and `GET /groups/{group_id}` additionally treat HTTP `404` (or `code: 404`) as "no longer exists" and not an error.
* **User endpoints** (`POST`/`PUT`/`DELETE` `/users`) ignore the body `code`. `PUT /users/{user_id}` maps HTTP `404` to "user not found", and `DELETE /users/{user_id}` treats HTTP `404` as success (already deleted).

Set `code` to the same value as the HTTP status (`404` for not found, `401` for not authorized) so both layers agree. The HTTP status itself determines success versus failure and whether Opal retries.

# Rate Limiting

If your custom connector enforces rate limits, return a `429` HTTP status. Opal honors the `Retry-After` response header on both `429` and `503` to decide how long to wait before retrying. The header accepts either of two formats:

* A non-negative integer number of seconds:

  ```
  Retry-After: 10
  ```

* An HTTP-date in [RFC 1123 format](https://datatracker.ietf.org/doc/html/rfc1123):

  ```
  Retry-After: Fri, 31 Dec 1999 23:59:59 GMT
  ```

Without a `Retry-After` header, Opal falls back to the exponential backoff described in [Status codes and retries](#status-codes-and-retries). The overall retry budget (3 retries, 4 attempts total) applies either way.

# Signature

To ensure that the API calls originate from Opal, we provide a header in each request that represents the encrypted request payload with a secret that is generated when creating the app connector in Opal. See [Setup connector app in Opal](https://docs.opal.dev/docs/how-to-create-your-own-connector.md#4-create-a-custom-app) for more details.

On each HTTP request that Opal sends, we add an `X-Opal-Signature` HTTP header. The signature is created by combining the signing secret with the body of the request we're sending using a standard HMAC-SHA256 keyed hash.

Here is an example with Node to compute the signature using your signing secret. You may compare it against the value retrieved from the `X-Opal-Signature` header:

```js Typescript theme={null}
const timestamp = request.header('X-Opal-Request-Timestamp')
const signingSecret = 'SIGNING_SECRET'
// In Typescript/JS, request.body is always an empty object
const sigBaseString = 'v0:' + timestamp + ':' + JSON.stringify(request.body)
const hmac = crypto.createHmac('sha256', signingSecret);
hmac.write(sigBaseString)
console.log(hmac.digest('hex'))
```

```Text Go (Gin) theme={null}
func generateSignature(
	signingSecret string,
	timestamp string,
	serializedBlob []byte,
) (string, error) {
	// Concatenate base string
	sigBaseString := "v0:" + timestamp + ":" + string(serializedBlob)

	// Hash base string to get signature
	hash := hmac.New(sha256.New, []byte(signingSecret))
	_, err := hash.Write([]byte(sigBaseString))
	if err != nil {
		return "", errors.Wrap(err, "error writing hash")
	}

	return hex.EncodeToString(hash.Sum(nil)), nil
}

func validateOpalSignature(signingSecret string) gin.HandlerFunc {
	return func(c *gin.Context) {
		opalSignature := c.GetHeader("X-Opal-Signature")
		if opalSignature == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, &Error{
				Code:    http.StatusUnauthorized,
				Message: "X-Opal-Signature header is missing",
			})
			return
		}
		opalRequestTimestamp := c.GetHeader("X-Opal-Request-Timestamp")
		if opalRequestTimestamp == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, &Error{
				Code:    http.StatusUnauthorized,
				Message: "X-Opal-Request-Timestamp header is missing",
			})
			return
		}

		var bodyStr string
		// Read request body, once the request body is read, it cannot be read again
		// so we need to save it in a variable and then reassign it to the Request.Body
		var bodyBytes []byte
		var err error
		if c.Request.Body != nil {
			bodyBytes, err = ioutil.ReadAll(c.Request.Body)
			if err != nil {
				c.AbortWithStatusJSON(http.StatusInternalServerError, &Error{
					Code:    http.StatusInternalServerError,
					Message: "Unable to read request body",
				})
				return
			}
			c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
			bodyStr = strings.TrimSpace(string(bodyBytes))
		}
		if bodyStr == "" {
			bodyStr = "{}"
		}

		signature, err := GenerateSignature(signingSecret, opalRequestTimestamp, []byte(bodyStr))
		if signature != opalSignature || err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, &Error{
				Code:    http.StatusUnauthorized,
				Message: "Invalid signature",
			})
			return
		}

		c.Next()
	}
}
```

Note: when the body is empty, do coalesce the empty/null stringified body to `{}`. See the link below for more examples.

Check [Create your own connector](https://docs.opal.dev/docs/how-to-create-your-own-connector.md#example-of-x-opal-signatures) for signature examples.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.