Summary and recommendation
Azure DevOps exposes user management across three distinct API surfaces: the Member Entitlement Management API (vsaex.dev.azure.com) for licensing and access levels, the Identity API (vssps.dev.azure.com) for group membership and permissions, and the SCIM 2.0 endpoint for Entra ID-backed provisioning.
These are not interchangeable-user IDs differ across surfaces (entitlement ID, identity ID, and member ID are separate), and mixing them without mapping is a common source of silent failures.
Authentication is via Personal Access Token (PAT) with base64 encoding in the Authorization header, or OAuth 2.0 for delegated flows; PATs are the practical default for automation. Stitchflow connects to Azure DevOps through an MCP server with ~100 deep IT/identity integrations, normalizing these fragmented surfaces into a single provisioning and audit layer.
API quick reference
| Has user API | Yes |
| Auth method | Personal Access Token (PAT) or OAuth 2.0 |
| Base URL | Official docs |
| SCIM available | Yes |
| SCIM plan required | Enterprise |
Authentication
Auth method: Personal Access Token (PAT) or OAuth 2.0
Setup steps
- Create a Personal Access Token in Azure DevOps user settings with appropriate scopes
- Encode PAT as base64 and include in Authorization header as 'Basic {base64_pat}'
- Alternatively, use OAuth 2.0 with Azure AD for delegated access
Required scopes
| Scope | Description | Required for |
|---|---|---|
| vso.identity | Read identity information | Reading user profiles and identities |
| vso.identity_manage | Manage identity and access | Creating, updating, and removing users |
| vso.memberentitlementmanagement | Manage member entitlements | Managing user access levels and licenses |
User object / data model
| Field | Type | Description | On create | On update | Notes |
|---|---|---|---|---|---|
| id | string (UUID) | Unique identifier for the user | auto-generated | read-only | Immutable identifier |
| principalName | string | User's principal name (email or UPN) | required | read-only | Must be unique within organization |
| displayName | string | User's display name | optional | writable | Synced from Azure AD if using AAD integration |
| mailAddress | string | User's email address | optional | writable | Used for notifications |
| accessLevel | string | User's access level (Stakeholder, Basic, Basic+Test Plans, etc.) | optional | writable | Determines feature access and licensing |
| accountStatus | string | Account status (active, disabled, pending, etc.) | auto-generated | writable | Controls user access to organization |
| origin | string | User origin (aad, msa, github, etc.) | auto-detected | read-only | Indicates identity provider source |
| subjectDescriptor | string | Subject descriptor for authorization | auto-generated | read-only | Used in group membership and permissions |
Core endpoints
List users in organization
- Method: GET
- URL:
https://vsaex.dev.azure.com/{organization}/_apis/userentitlements?api-version=7.1-preview.3 - Watch out for: Requires vso.memberentitlementmanagement scope. Returns entitlements, not raw users.
Request example
GET /userentitlements?$skip=0&$top=100
Response example
{"value":[{"id":"user-id","principalName":"user@example.com","displayName":"User Name","accessLevel":{"accountLicenseType":"Basic"}}],"continuationToken":null}
Get user by ID
- Method: GET
- URL:
https://vsaex.dev.azure.com/{organization}/_apis/userentitlements/{userId}?api-version=7.1-preview.3 - Watch out for: userId is the entitlement ID, not the identity ID. Different from Graph API user IDs.
Request example
GET /userentitlements/{userId}
Response example
{"id":"user-id","principalName":"user@example.com","displayName":"User Name","accessLevel":{"accountLicenseType":"Basic"},"accountStatus":"active"}
Add user to organization
- Method: POST
- URL:
https://vsaex.dev.azure.com/{organization}/_apis/userentitlements?api-version=7.1-preview.3 - Watch out for: New users start in 'pending' status. They must accept invitation. Azure AD users are auto-added if org is AAD-backed.
Request example
{"accessLevel":{"accountLicenseType":"Basic"},"principalName":"newuser@example.com"}
Response example
{"id":"new-user-id","principalName":"newuser@example.com","accessLevel":{"accountLicenseType":"Basic"},"accountStatus":"pending"}
Update user access level
- Method: PATCH
- URL:
https://vsaex.dev.azure.com/{organization}/_apis/userentitlements/{userId}?api-version=7.1-preview.3 - Watch out for: Changing access levels may incur licensing costs. Some downgrades require explicit confirmation.
Request example
{"accessLevel":{"accountLicenseType":"Basic+TestPlans"}}
Response example
{"id":"user-id","accessLevel":{"accountLicenseType":"Basic+TestPlans"}}
Remove user from organization
- Method: DELETE
- URL:
https://vsaex.dev.azure.com/{organization}/_apis/userentitlements/{userId}?api-version=7.1-preview.3 - Watch out for: Deletion is permanent. User loses access to all projects. Cannot be undone via API.
Request example
DELETE /userentitlements/{userId}
Response example
HTTP 204 No Content
List organization members
- Method: GET
- URL:
https://dev.azure.com/{organization}/_apis/members?api-version=7.1-preview.1 - Watch out for: Different endpoint than userentitlements. Returns members with less detail. Use for quick lookups.
Request example
GET /members?$skip=0&$top=100
Response example
{"value":[{"id":"member-id","displayName":"User Name","uniqueName":"user@example.com"}]}
Get user identity
- Method: GET
- URL:
https://vssps.dev.azure.com/{organization}/_apis/identities?searchFilter=General&filterValue={email}&api-version=7.1-preview.1 - Watch out for: Identity API is separate from entitlements. Used for permissions and group membership, not licensing.
Request example
GET /identities?searchFilter=General&filterValue=user@example.com
Response example
{"value":[{"id":"identity-id","displayName":"User Name","uniqueName":"user@example.com","descriptor":"aad.xxx"}]}
Batch add users
- Method: POST
- URL:
https://vsaex.dev.azure.com/{organization}/_apis/userentitlements/batch?api-version=7.1-preview.3 - Watch out for: Batch operations are more efficient. Failures are per-user; partial success is possible.
Request example
{"userEntitlements":[{"principalName":"user1@example.com","accessLevel":{"accountLicenseType":"Basic"}},{"principalName":"user2@example.com","accessLevel":{"accountLicenseType":"Stakeholder"}}]}
Response example
{"value":[{"id":"user1-id","principalName":"user1@example.com","accountStatus":"pending"},{"id":"user2-id","principalName":"user2@example.com","accountStatus":"pending"}]}
Rate limits, pagination, and events
- Rate limits: Azure DevOps enforces rate limits based on user tier and API usage patterns
- Rate-limit headers: Yes
- Retry-After header: Yes
- Rate-limit notes: Rate limits are per-user and per-IP. Throttling returns HTTP 429. Implement exponential backoff for retries.
- Pagination method: offset
- Default page size: 200
- Max page size: 10000
- Pagination pointer: $skip and $top
| Plan | Limit | Concurrent |
|---|---|---|
| Free/Basic | 1800 requests per minute per user | 0 |
| Enterprise | Higher limits available | 0 |
- Webhooks available: Yes
- Webhook notes: Azure DevOps supports service hooks for user-related events via subscriptions API
- Alternative event strategy: Use Graph API change notifications or poll userentitlements endpoint for changes
- Webhook events: User added to organization, User removed from organization, User access level changed
SCIM API status
SCIM available: Yes
SCIM version: 2.0
Plan required: Enterprise
Endpoint: https://dev.azure.com/{organization}/_apis/scim/v2
Supported operations: Create user (POST /Users), Read user (GET /Users/{id}), Update user (PATCH /Users/{id}), Delete user (DELETE /Users/{id}), List users (GET /Users), Create group (POST /Groups), Add user to group (PATCH /Groups/{id})
Limitations:
- Enterprise plan required
- Must be configured in organization settings
- Requires Azure AD as identity provider
- SCIM operations sync with Azure AD; direct SCIM changes may be overwritten by AAD sync
- Group operations limited to Azure AD groups
Common scenarios
Three scenarios cover the majority of programmatic user management needs:
Provision a new user with Basic access: POST to
https://vsaex.dev.azure.com/{organization}/_apis/userentitlements?api-version=7.1-preview.3withprincipalNameandaccessLevel=Basic. For Entra ID-backed orgs, users are auto-added from AAD and skip the invitation flow; for MSA/GitHub identities, the user starts inpendingstatus until they accept the invitation email. Verify final state with a GET on the same endpoint using the returneduserId.Downgrade a user from Basic to Stakeholder: PATCH
/userentitlements/{userId}withaccessLevel=Stakeholder. Some downgrades require explicit confirmation in the response; handle HTTP 400 responses that indicate a pending approval step. After downgrading, manually reduce the paid user count in Organization Settings-the API does not do this automatically.Bulk provisioning via SCIM (Entra ID-backed orgs only): POST to the SCIM
/Usersendpoint in batches. SCIM syncs with Entra ID approximately every 40 minutes; direct SCIM writes can be overwritten by the next AAD sync cycle, so Entra ID must be treated as the authoritative source of truth. SCIM is not a replacement for the REST API-it does not cover all operations, and the REST API remains necessary for access level management and permissions.
Provision new user with Basic license
- POST to /userentitlements with principalName and accessLevel=Basic
- User receives invitation email (if not AAD-backed)
- User accepts invitation and gains access
- Verify user status via GET /userentitlements/{userId}
Watch out for: AAD-backed orgs auto-add users; manual invitations only for MSA/GitHub. New users start in 'pending' status.
Bulk import users from CSV with SCIM (Enterprise only)
- Ensure organization is Enterprise and AAD-backed
- Enable SCIM in organization settings
- POST batch user creation to SCIM /Users endpoint
- Monitor sync status; SCIM syncs with AAD every 40 minutes
- Verify users appear in organization
Watch out for: SCIM changes may be overwritten by AAD sync. Use AAD as source of truth for Enterprise orgs.
Downgrade user from Basic to Stakeholder to reduce costs
- PATCH /userentitlements/{userId} with accessLevel=Stakeholder
- Confirm licensing change (may require approval)
- User retains access but loses feature access (e.g., test plans)
- Verify change via GET /userentitlements/{userId}
Watch out for: Some access level changes require explicit confirmation. Downgrading may fail if user has pending work items or test plans in use.
Why building this yourself is a trap
The primary API trap is the multi-surface identity model: a user's entitlement ID (used for licensing), identity ID (used for permissions and group membership), and member ID (used in the members endpoint) are all different and not cross-referenceable without additional lookups. Automation that conflates these will silently operate on the wrong object.
A second trap is the Entra ID sync dependency: for AAD-backed organizations, removing a user via the REST API does not remove them from Entra ID groups, and those group memberships will re-provision access on the user's next sign-in-the same offboarding loop that affects manual management.
Rate limits are enforced at 1,800 requests per minute per user/IP; batch endpoints (/userentitlements/batch) are the correct pattern for bulk operations, and exponential backoff is required on HTTP 429 responses.
Finally, deleted users cannot be restored via API-there is no soft-delete or deactivation state within Azure DevOps itself; disabling access without removal requires acting on the Entra ID account directly.
Automate Azure DevOps workflows without one-off scripts
Stitchflow builds and maintains end-to-end IT automation across your SaaS stack, including apps without APIs. Built for exactly how your company works, with human approvals where they matter.