Directory synchronisation
These endpoints read your organisation's Microsoft Entra ID directory and turn what they find into VDF AI user accounts and permission groups. They use the directory connection configured by an administrator; Retrieve directory status reports whether it is ready.
An import never changes an existing account. A directory user who already has an account, either linked to the same directory identity or with the same email address, is reported as existing. New accounts sign in with Microsoft (auth_source is entra) and have no password, and creating them uses licence seats.
The import and sync endpoints accept dry_run, which reports what would change without writing anything. They read at most 999 direct user members of each directory group; members of nested groups aren't included.
Administrator access. Every endpoint on this page requires an administrator account.
- GET /admin/directory/entra/status Retrieve directory status
- GET /admin/directory/entra/users List directory users
- GET /admin/directory/entra/groups List directory groups
- GET /admin/directory/entra/groups/{group_id}/members List directory group members
- POST /admin/directory/entra/import/users Import directory users
- POST /admin/directory/entra/import/groups Import directory groups
- POST /admin/directory/entra/sync Sync linked groups
Paths are relative to /api
The directory user object
A user in your Microsoft Entra ID directory. The import endpoints return the same fields plus userId, the ID of the matching account, or reason for a user they skipped.
Attributes
-
idstringThe user's object ID in the directory.
-
displayNamenullable stringThe user's display name.
-
emailstringThe user's mail address, or their user principal name when they have none, in lower case. Empty when the directory has neither.
-
userPrincipalNamenullable stringThe user's sign-in name in the directory.
-
accountEnabledbooleanWhether the account is enabled in the directory.
{
"id": "9d3a7c2e-4b1f-4e8a-a6c5-2f7e1b0d8c34",
"displayName": "Grace Hopper",
"email": "grace@example.com",
"userPrincipalName": "grace@example.com",
"accountEnabled": true
} Retrieve directory status
GET /api/admin/directory/entra/status
Reports whether the deployment can read your Microsoft Entra ID directory.
Checks the directory connection by listing a user and a group. It always responds with 200, so read ready.
- When
readyistrue,tenantdescribes the directory: itsid,displayNameandverifiedDomains.displayNameisnullandverifiedDomainsis empty when the deployment isn't allowed to read the organisation's details. - When
readyisfalse,errordescribes the problem, andremediableistruewhen an administrator can fix it in Microsoft Entra ID.
- Authentication
- Bearer token How it works
- Permission
- Requires an administrator account.
Parameters
No parameters.
Returns
Returns whether the directory can be read and, if so, which directory it is.
curl "$VDF_BASE_URL/api/admin/directory/entra/status" \
-H "Authorization: Bearer $VDF_ACCESS_TOKEN" const response = await fetch(`${process.env.VDF_BASE_URL}/api/admin/directory/entra/status`, {
headers: {
Authorization: `Bearer ${process.env.VDF_ACCESS_TOKEN}`,
},
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
const data = await response.json(); import os
import requests
response = requests.get(
f"{os.environ['VDF_BASE_URL']}/api/admin/directory/entra/status",
headers={"Authorization": f"Bearer {os.environ['VDF_ACCESS_TOKEN']}"},
timeout=30,
)
response.raise_for_status()
data = response.json() {
"success": true,
"ready": true,
"tenant": {
"id": "00000000-0000-0000-0000-000000000000",
"displayName": "Contoso",
"verifiedDomains": [
"example.com"
]
}
} List directory users
GET /api/admin/directory/entra/users
Returns users from your Microsoft Entra ID directory.
Returns directory users, in the order the directory lists them, for choosing whom to bring in with Import directory users. Accounts disabled in the directory are included, with accountEnabled set to false.
- Authentication
- Bearer token How it works
- Permission
- Requires an administrator account.
Query parameters
-
searchstringOnly users whose display name, user principal name or mail address starts with this text.
-
limitintegerThe most users to return, from 1 to 500.
Returns
Returns a list of directory user objects.
Errors
- 400 The directory connection isn't configured.
remediableistrue. - 403 The directory refused the request because the directory connection lacks a permission it needs.
remediableistrue. - 429 The directory is limiting the rate of requests. Try again shortly.
- 502 The directory couldn't be reached or returned an error.
curl "$VDF_BASE_URL/api/admin/directory/entra/users?limit=100" \
-H "Authorization: Bearer $VDF_ACCESS_TOKEN" const response = await fetch(`${process.env.VDF_BASE_URL}/api/admin/directory/entra/users?limit=100`, {
headers: {
Authorization: `Bearer ${process.env.VDF_ACCESS_TOKEN}`,
},
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
const data = await response.json(); import os
import requests
response = requests.get(
f"{os.environ['VDF_BASE_URL']}/api/admin/directory/entra/users",
headers={"Authorization": f"Bearer {os.environ['VDF_ACCESS_TOKEN']}"},
params={
"limit": 100,
},
timeout=30,
)
response.raise_for_status()
data = response.json() {
"success": true,
"users": [
{
"id": "9d3a7c2e-4b1f-4e8a-a6c5-2f7e1b0d8c34",
"displayName": "Grace Hopper",
"email": "grace@example.com",
"userPrincipalName": "grace@example.com",
"accountEnabled": true
},
{
"id": "c4f6a8b0-2d1e-4f3a-9b5c-7e0d2a4c6f81",
"displayName": "Charles Babbage",
"email": "charles@example.com",
"userPrincipalName": "charles@example.com",
"accountEnabled": false
}
]
} List directory groups
GET /api/admin/directory/entra/groups
Returns groups from your Microsoft Entra ID directory.
Returns directory groups, in the order the directory lists them, for choosing which to bring in with Import directory groups. Groups of every type are included; securityEnabled is true for security groups.
- Authentication
- Bearer token How it works
- Permission
- Requires an administrator account.
Query parameters
-
searchstringOnly groups whose display name starts with this text.
-
limitintegerThe most groups to return, from 1 to 500.
Returns
Returns a list of directory groups, each with its object id, displayName, description, securityEnabled and mailNickname.
Errors
- 400 The directory connection isn't configured.
remediableistrue. - 403 The directory refused the request because the directory connection lacks a permission it needs.
remediableistrue. - 429 The directory is limiting the rate of requests. Try again shortly.
- 502 The directory couldn't be reached or returned an error.
curl "$VDF_BASE_URL/api/admin/directory/entra/groups?search=Eng" \
-H "Authorization: Bearer $VDF_ACCESS_TOKEN" const response = await fetch(`${process.env.VDF_BASE_URL}/api/admin/directory/entra/groups?search=Eng`, {
headers: {
Authorization: `Bearer ${process.env.VDF_ACCESS_TOKEN}`,
},
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
const data = await response.json(); import os
import requests
response = requests.get(
f"{os.environ['VDF_BASE_URL']}/api/admin/directory/entra/groups",
headers={"Authorization": f"Bearer {os.environ['VDF_ACCESS_TOKEN']}"},
params={
"search": "Eng",
},
timeout=30,
)
response.raise_for_status()
data = response.json() {
"success": true,
"groups": [
{
"id": "3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b",
"displayName": "Engineering",
"description": "Engineering staff",
"securityEnabled": true,
"mailNickname": "engineering"
}
]
} List directory group members
GET /api/admin/directory/entra/groups/{group_id}/members
Returns the user members of a directory group.
Returns the group's direct user members. Members of nested groups, and members that aren't users, aren't included.
- Authentication
- Bearer token How it works
- Permission
- Requires an administrator account.
Path parameters
-
group_idstring RequiredThe directory group's object ID, as returned by List directory groups.
Query parameters
-
limitintegerThe most members to return, from 1 to 500.
Returns
Returns a list of directory user objects.
Errors
- 400 The directory connection isn't configured.
remediableistrue. - 403 The directory refused the request because the directory connection lacks a permission it needs.
remediableistrue. - 429 The directory is limiting the rate of requests. Try again shortly.
- 502 The directory couldn't be reached or returned an error, for example because no group has this ID.
curl "$VDF_BASE_URL/api/admin/directory/entra/groups/3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b/members" \
-H "Authorization: Bearer $VDF_ACCESS_TOKEN" const response = await fetch(`${process.env.VDF_BASE_URL}/api/admin/directory/entra/groups/3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b/members`, {
headers: {
Authorization: `Bearer ${process.env.VDF_ACCESS_TOKEN}`,
},
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
const data = await response.json(); import os
import requests
response = requests.get(
f"{os.environ['VDF_BASE_URL']}/api/admin/directory/entra/groups/3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b/members",
headers={"Authorization": f"Bearer {os.environ['VDF_ACCESS_TOKEN']}"},
timeout=30,
)
response.raise_for_status()
data = response.json() {
"success": true,
"members": [
{
"id": "9d3a7c2e-4b1f-4e8a-a6c5-2f7e1b0d8c34",
"displayName": "Grace Hopper",
"email": "grace@example.com",
"userPrincipalName": "grace@example.com",
"accountEnabled": true
},
{
"id": "c4f6a8b0-2d1e-4f3a-9b5c-7e0d2a4c6f81",
"displayName": "Charles Babbage",
"email": "charles@example.com",
"userPrincipalName": "charles@example.com",
"accountEnabled": false
}
]
} Import directory users
POST /api/admin/directory/entra/import/users
Creates accounts for the directory users you select.
Brings the selected directory users into VDF AI. Each ID is read again from the directory, so the import uses the directory's current details.
- A user who already has an account is listed under
existingand left unchanged. - A user whose account is disabled in the directory, or who has no email address there, is listed under
skippedwith areason. - Everyone else gets a new account, listed under
created. It signs in with Microsoft, has no password, takes its name and email address from the directory, and has the roledefault_role.
IDs the directory no longer knows are returned in unresolved. With group_ids, every created and existing user also joins those permission groups as a local member, so a sync doesn't remove them. The response also gives the role used for new accounts, how many members each group added, and seats, the licence position before the import.
The import is a single transaction. When the licence limits active users and the new accounts don't all fit, it stops and nothing is created. With dry_run, nothing is written: created lists the users who would get an account, with userId set to null, and seats is null if the licence doesn't currently allow adding users.
- Authentication
- Bearer token How it works
- Permission
- Requires an administrator account.
Body parameters application/json
-
object_idsarray of strings RequiredObject IDs of the directory users to import, as returned by List directory users. At most 500. Also accepted as
objectIds. -
default_rolestringThe role for new accounts, from the values listed under
user_typein Create a user. An unrecognised value is treated asdefault. When omitted, new accounts get the role your deployment gives new Microsoft sign-ins. Also accepted asdefaultRole. -
group_idsarray of integersIDs of permission groups that every created and existing user joins. Each group must exist and be active. Also accepted as
groupIds. -
dry_runbooleanReport what would happen without creating accounts or memberships. Also accepted as
dryRun.
Returns
Returns the users created, found and skipped, the group assignments and the seat position.
Errors
- 400
object_idsis missing or empty, or lists more than 500 IDs (the response includesmaxSelection);group_idsisn't a list of integers; a group ingroup_idsis inactive; or the directory connection isn't configured. - 402 The licence doesn't currently allow adding users. Dry runs aren't affected.
- 403 The licence limits active users and the new accounts don't fit in the free seats, so nothing was imported (the response's
errorisUSER_LIMIT_REACHED); or the directory refused the request because the directory connection lacks a permission it needs. - 404 None of the IDs exist in the directory any more (they are listed in
unresolved), or a group ingroup_idsdoesn't exist. - 429 The directory is limiting the rate of requests. Try again shortly.
- 502 The directory couldn't be reached or returned an error.
curl -X POST "$VDF_BASE_URL/api/admin/directory/entra/import/users" \
-H "Authorization: Bearer $VDF_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"object_ids": [
"e2a4c6d8-1f3b-4d5e-8a7c-9b0d2f4e6a13",
"5e8b1d4f-7a2c-4c9e-b3d6-8a1f0e2c7b95"
],
"default_role": "default",
"group_ids": [
8
]
}' const response = await fetch(`${process.env.VDF_BASE_URL}/api/admin/directory/entra/import/users`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VDF_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
object_ids: [
'e2a4c6d8-1f3b-4d5e-8a7c-9b0d2f4e6a13',
'5e8b1d4f-7a2c-4c9e-b3d6-8a1f0e2c7b95',
],
default_role: 'default',
group_ids: [8],
}),
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
const data = await response.json(); import os
import requests
response = requests.post(
f"{os.environ['VDF_BASE_URL']}/api/admin/directory/entra/import/users",
headers={"Authorization": f"Bearer {os.environ['VDF_ACCESS_TOKEN']}"},
json={
"object_ids": [
"e2a4c6d8-1f3b-4d5e-8a7c-9b0d2f4e6a13",
"5e8b1d4f-7a2c-4c9e-b3d6-8a1f0e2c7b95",
],
"default_role": "default",
"group_ids": [8],
},
timeout=30,
)
response.raise_for_status()
data = response.json() {
"success": true,
"dryRun": false,
"role": "default",
"created": [
{
"id": "e2a4c6d8-1f3b-4d5e-8a7c-9b0d2f4e6a13",
"displayName": "Mary Jackson",
"email": "mary@example.com",
"userPrincipalName": "mary@example.com",
"accountEnabled": true,
"userId": 47
}
],
"existing": [
{
"id": "5e8b1d4f-7a2c-4c9e-b3d6-8a1f0e2c7b95",
"displayName": "Ada Lovelace",
"email": "ada@example.com",
"userPrincipalName": "ada@example.com",
"accountEnabled": true,
"userId": 42
}
],
"skipped": [],
"groups": [
{
"groupId": 8,
"slug": "finance",
"name": "Finance",
"added": 2
}
],
"seats": {
"maxUsers": 50,
"activeUsers": 12,
"seatsRemaining": 38,
"enforced": true
},
"unresolved": []
} Import directory groups
POST /api/admin/directory/entra/import/groups
Links directory groups to permission groups and, optionally, brings in their members.
Links each directory group to a permission group, creating the permission group if none is linked yet. The IDs are processed in order:
- The group is read from the directory. A new permission group takes the directory group's name and description, a slug derived from the name (or from the group's ID when the name has no usable characters) with a numeric suffix if it is taken, and
sourceset toentra. An already-linked permission group takes the directory group's current name and description, and itssourcebecomesentra; its slug and active state are kept. - With
include_members, the group's direct user members are read. Withprovision_missing_users, members without an account get one, as in Import directory users. The group'sentramembership is then brought in line with the directory: members who have an account are added, andentramembers who are no longer in the directory group are removed. Members added by an administrator are kept.
Each group is saved as it is processed, so if the request fails part-way, for example because an ID can't be read from the directory or no licence seats remain, the work already done stays in place. Importing the same group again updates it rather than creating a second one.
Each entry of the response's groups reports entraGroupId, displayName, whether the permission group was created, its slug and groupId, the directoryMemberCount, usersCreated (the members given an account or, in a dry run or without provision_missing_users, who would be), usersSkipped (directory users with a reason), and membership, the numbers of entra members added and removed and of members skipped because no account matched. The member fields appear only with include_members. With dry_run, nothing is written: entries have no groupId or membership, and slug is the proposed slug before any numeric suffix.
- Authentication
- Bearer token How it works
- Permission
- Requires an administrator account.
Body parameters application/json
-
group_idsarray of strings RequiredObject IDs of the directory groups to import, as returned by List directory groups. Also accepted as
groupIds. -
include_membersbooleanRead each group's direct user members and bring the permission group's
entramembership in line with them. Also accepted asincludeMembers. -
provision_missing_usersbooleanCreate accounts for members who don't have one. Accounts disabled in the directory, and users without an email address there, are skipped. Has no effect unless
include_membersistrue. Also accepted asprovisionMissingUsers. -
default_rolestringThe role for accounts created by
provision_missing_users, as for Import directory users. Also accepted asdefaultRole. -
dry_runbooleanReport what would happen without creating or changing groups, accounts or memberships. Also accepted as
dryRun.
Returns
Returns one entry for each directory group processed.
Errors
- 400
group_idsis missing or empty, or the directory connection isn't configured. - 402 The licence doesn't currently allow adding users. Only requests that create accounts are affected.
- 403 The licence limits active users and the members' new accounts don't fit in the free seats (the response's
errorisUSER_LIMIT_REACHED); or the directory refused the request because the directory connection lacks a permission it needs. - 429 The directory is limiting the rate of requests. Try again shortly.
- 502 The directory couldn't be reached or returned an error, for example because an ID in
group_idsisn't a directory group.
curl -X POST "$VDF_BASE_URL/api/admin/directory/entra/import/groups" \
-H "Authorization: Bearer $VDF_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"group_ids": [
"3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b"
],
"include_members": true,
"provision_missing_users": true,
"default_role": "default"
}' const response = await fetch(`${process.env.VDF_BASE_URL}/api/admin/directory/entra/import/groups`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VDF_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
group_ids: ['3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b'],
include_members: true,
provision_missing_users: true,
default_role: 'default',
}),
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
const data = await response.json(); import os
import requests
response = requests.post(
f"{os.environ['VDF_BASE_URL']}/api/admin/directory/entra/import/groups",
headers={"Authorization": f"Bearer {os.environ['VDF_ACCESS_TOKEN']}"},
json={
"group_ids": ["3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b"],
"include_members": True,
"provision_missing_users": True,
"default_role": "default",
},
timeout=30,
)
response.raise_for_status()
data = response.json() {
"success": true,
"dryRun": false,
"groups": [
{
"entraGroupId": "3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b",
"displayName": "Engineering",
"created": true,
"groupId": 7,
"slug": "engineering",
"directoryMemberCount": 2,
"usersCreated": 1,
"usersSkipped": [
{
"id": "c4f6a8b0-2d1e-4f3a-9b5c-7e0d2a4c6f81",
"displayName": "Charles Babbage",
"email": "charles@example.com",
"userPrincipalName": "charles@example.com",
"accountEnabled": false,
"reason": "account is disabled in the directory"
}
],
"membership": {
"added": 1,
"removed": 0,
"skipped": 0
}
}
]
} Sync linked groups
POST /api/admin/directory/entra/sync
Reads from the directory the members of every directory group that a permission group is linked to (every permission group with an entraGroupId, active or not), and brings each permission group's entra membership in line: members who have an account are added, and entra members who are no longer in the directory group are removed. Members added by an administrator are kept, and the group's lastSyncedAt is updated.
A sync never creates, changes or deactivates accounts. Directory members without an account are counted in unprovisioned; give them accounts with Import directory users.
Each entry of the response's groups reports the groupId, slug, directoryMemberCount, unprovisioned and membership, as for Import directory groups. A group whose members can't be read is reported with an error, and the sync carries on with the others. With dry_run, nothing is changed and entries have no membership.
- Authentication
- Bearer token How it works
- Permission
- Requires an administrator account.
Body parameters application/json
-
dry_runbooleanReport the directory's membership counts without changing any membership. Also accepted as
dryRun.
Returns
Returns one entry for each linked group.
Errors
- 400 The directory connection isn't configured and at least one group is linked.
remediableistrue.
curl -X POST "$VDF_BASE_URL/api/admin/directory/entra/sync" \
-H "Authorization: Bearer $VDF_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dry_run": false
}' const response = await fetch(`${process.env.VDF_BASE_URL}/api/admin/directory/entra/sync`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VDF_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dry_run: false,
}),
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
const data = await response.json(); import os
import requests
response = requests.post(
f"{os.environ['VDF_BASE_URL']}/api/admin/directory/entra/sync",
headers={"Authorization": f"Bearer {os.environ['VDF_ACCESS_TOKEN']}"},
json={
"dry_run": False,
},
timeout=30,
)
response.raise_for_status()
data = response.json() {
"success": true,
"dryRun": false,
"groups": [
{
"groupId": 7,
"slug": "engineering",
"directoryMemberCount": 3,
"unprovisioned": 1,
"membership": {
"added": 1,
"removed": 0,
"skipped": 0
}
}
]
}