Azure DevOps REST API: Complete Reference
A comprehensive reference guide to the Azure DevOps REST API, covering authentication methods, core API areas (work items, builds, repos, pipelines, artifacts), pagination, rate limiting, batch requests, and practical automation examples with working Node.js code.
Azure DevOps REST API: Complete Reference
Overview
The Azure DevOps REST API is how you automate everything that the web portal does manually. Every feature in Azure DevOps — work items, repositories, pipelines, artifacts, test plans, wikis — has a corresponding REST API. I use these APIs daily for automation scripts, custom integrations, and reporting tools that the built-in features do not support. Understanding the API structure, authentication patterns, and common pitfalls saves you hours of trial and error with incomplete documentation.
Prerequisites
- Azure DevOps organization and project
- Personal Access Token (PAT) or Azure AD application registration
- Node.js 16 or later for the code examples
curlor similar HTTP client for testing- Familiarity with REST API concepts (HTTP methods, status codes, JSON)
Authentication
Personal Access Tokens (PATs)
PATs are the simplest authentication method for scripts and tools. Create one at https://dev.azure.com/{org}/_usersettings/tokens.
PATs use HTTP Basic authentication with an empty username:
# curl with PAT
curl -s \
-u ":YOUR_PAT_HERE" \
"https://dev.azure.com/your-org/your-project/_apis/build/builds?api-version=7.1"
# Or with explicit Base64 encoding
TOKEN=$(echo -n ":YOUR_PAT_HERE" | base64)
curl -s \
-H "Authorization: Basic $TOKEN" \
"https://dev.azure.com/your-org/your-project/_apis/build/builds?api-version=7.1"
In Node.js:
// api-client.js
var https = require("https");
function createClient(org, pat) {
var auth = Buffer.from(":" + pat).toString("base64");
return {
request: function (method, path, body, callback) {
var options = {
hostname: "dev.azure.com",
path: "/" + org + path,
method: method,
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json",
"Accept": "application/json"
}
};
var req = https.request(options, function (res) {
var data = "";
res.on("data", function (chunk) { data += chunk; });
res.on("end", function () {
var parsed;
try { parsed = JSON.parse(data); } catch (e) { parsed = data; }
if (res.statusCode >= 400) {
var error = new Error("API error " + res.statusCode + ": " + (parsed.message || data));
error.statusCode = res.statusCode;
error.body = parsed;
return callback(error);
}
callback(null, parsed, res.headers);
});
});
req.on("error", callback);
if (body) { req.write(JSON.stringify(body)); }
req.end();
},
get: function (path, callback) {
this.request("GET", path, null, callback);
},
post: function (path, body, callback) {
this.request("POST", path, body, callback);
},
patch: function (path, body, callback) {
// Work item updates use json-patch+json
var auth2 = Buffer.from(":" + pat).toString("base64");
var options = {
hostname: "dev.azure.com",
path: "/" + org + path,
method: "PATCH",
headers: {
"Authorization": "Basic " + auth2,
"Content-Type": "application/json-patch+json",
"Accept": "application/json"
}
};
var req = https.request(options, function (res) {
var data = "";
res.on("data", function (chunk) { data += chunk; });
res.on("end", function () {
var parsed;
try { parsed = JSON.parse(data); } catch (e) { parsed = data; }
if (res.statusCode >= 400) {
var error = new Error("API error " + res.statusCode);
error.body = parsed;
return callback(error);
}
callback(null, parsed);
});
});
req.on("error", callback);
if (body) { req.write(JSON.stringify(body)); }
req.end();
}
};
}
module.exports = { createClient: createClient };
OAuth 2.0 with Azure AD
For applications that act on behalf of users, use OAuth:
// oauth-client.js
var https = require("https");
var querystring = require("querystring");
var oauthConfig = {
clientId: process.env.AZURE_CLIENT_ID,
clientSecret: process.env.AZURE_CLIENT_SECRET,
tenantId: process.env.AZURE_TENANT_ID,
scope: "499b84ac-1321-427f-aa17-267ca6975798/.default" // Azure DevOps resource
};
function getAccessToken(callback) {
var body = querystring.stringify({
grant_type: "client_credentials",
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
scope: oauthConfig.scope
});
var options = {
hostname: "login.microsoftonline.com",
path: "/" + oauthConfig.tenantId + "/oauth2/v2.0/token",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function (res) {
var data = "";
res.on("data", function (chunk) { data += chunk; });
res.on("end", function () {
var parsed = JSON.parse(data);
if (parsed.error) {
return callback(new Error(parsed.error_description));
}
callback(null, parsed.access_token, parsed.expires_in);
});
});
req.on("error", callback);
req.write(body);
req.end();
}
module.exports = { getAccessToken: getAccessToken };
API URL Structure
All Azure DevOps REST APIs follow this pattern:
https://dev.azure.com/{organization}/{project}/_apis/{area}/{resource}?api-version={version}
Some APIs live on different hosts:
- Core APIs:
dev.azure.com/{org} - Release management:
vsrm.dev.azure.com/{org} - Feed/artifact APIs:
feeds.dev.azure.com/{org} - Visual Studio extensions:
extmgmt.dev.azure.com/{org} - Analytics/reporting:
analytics.dev.azure.com/{org}
Always specify api-version. Without it, you get the oldest supported version, which may be missing fields you expect.
Work Items API
Create a Work Item
function createWorkItem(client, project, type, fields, callback) {
var patchDoc = Object.keys(fields).map(function (key) {
return {
op: "add",
path: "/fields/" + key,
value: fields[key]
};
});
client.patch("/" + project + "/_apis/wit/workitems/$" + type + "?api-version=7.1", patchDoc, callback);
}
// Usage
var client = require("./api-client").createClient("your-org", process.env.AZURE_PAT);
createWorkItem(client, "your-project", "User Story", {
"System.Title": "Implement user profile page",
"System.Description": "Create a user profile page with avatar, bio, and settings",
"System.AssignedTo": "[email protected]",
"System.IterationPath": "your-project\\Sprint 5",
"System.AreaPath": "your-project\\Frontend",
"Microsoft.VSTS.Common.Priority": 2
}, function (err, result) {
if (err) { return console.error(err); }
console.log("Created work item #" + result.id + ": " + result.fields["System.Title"]);
});
Query Work Items with WIQL
function queryWorkItems(client, project, wiql, callback) {
client.post("/" + project + "/_apis/wit/wiql?api-version=7.1", {
query: wiql
}, function (err, result) {
if (err) { return callback(err); }
if (!result.workItems || result.workItems.length === 0) {
return callback(null, []);
}
// WIQL returns IDs only — fetch full details in batches
var ids = result.workItems.map(function (wi) { return wi.id; });
var batchSize = 200; // API limit
var batches = [];
for (var i = 0; i < ids.length; i += batchSize) {
batches.push(ids.slice(i, i + batchSize));
}
var allItems = [];
var completed = 0;
batches.forEach(function (batch) {
var idsParam = batch.join(",");
client.get("/" + project + "/_apis/wit/workitems?ids=" + idsParam +
"&$expand=relations&api-version=7.1", function (err2, data) {
if (err2) { return callback(err2); }
allItems = allItems.concat(data.value);
completed++;
if (completed === batches.length) {
callback(null, allItems);
}
});
});
});
}
// Usage: Get all active bugs assigned to me
queryWorkItems(client, "your-project",
"SELECT [System.Id], [System.Title], [System.State] " +
"FROM WorkItems " +
"WHERE [System.WorkItemType] = 'Bug' " +
"AND [System.State] = 'Active' " +
"AND [System.AssignedTo] = @me " +
"ORDER BY [Microsoft.VSTS.Common.Priority] ASC",
function (err, items) {
if (err) { return console.error(err); }
items.forEach(function (item) {
console.log("#" + item.id + " [P" + item.fields["Microsoft.VSTS.Common.Priority"] + "] " +
item.fields["System.Title"]);
});
}
);
Update a Work Item
function updateWorkItem(client, project, id, operations, callback) {
client.patch("/" + project + "/_apis/wit/workitems/" + id + "?api-version=7.1", operations, callback);
}
// Move to Done and add a comment
updateWorkItem(client, "your-project", 1234, [
{ op: "add", path: "/fields/System.State", value: "Done" },
{ op: "add", path: "/fields/System.History", value: "Completed implementation and all tests pass." }
], function (err, result) {
if (err) { return console.error(err); }
console.log("Updated work item #" + result.id + " to " + result.fields["System.State"]);
});
Builds and Pipelines API
List Recent Builds
function listBuilds(client, project, options, callback) {
var query = [];
if (options.top) { query.push("$top=" + options.top); }
if (options.statusFilter) { query.push("statusFilter=" + options.statusFilter); }
if (options.resultFilter) { query.push("resultFilter=" + options.resultFilter); }
if (options.definitionId) { query.push("definitions=" + options.definitionId); }
if (options.branchName) { query.push("branchName=" + encodeURIComponent(options.branchName)); }
query.push("api-version=7.1");
client.get("/" + project + "/_apis/build/builds?" + query.join("&"), callback);
}
// Get last 10 failed builds
listBuilds(client, "your-project", {
top: 10,
resultFilter: "failed"
}, function (err, data) {
if (err) { return console.error(err); }
data.value.forEach(function (build) {
console.log("#" + build.buildNumber + " — " + build.definition.name +
" — " + build.result + " — " + build.sourceBranch);
});
});
Queue a Build
function queueBuild(client, project, definitionId, params, callback) {
var body = {
definition: { id: definitionId },
sourceBranch: params.branch || "refs/heads/main",
parameters: params.variables ? JSON.stringify(params.variables) : undefined
};
client.post("/" + project + "/_apis/build/builds?api-version=7.1", body, callback);
}
// Queue a build with custom variables
queueBuild(client, "your-project", 42, {
branch: "refs/heads/feature/new-api",
variables: { deployTarget: "staging", runTests: "true" }
}, function (err, build) {
if (err) { return console.error(err); }
console.log("Queued build #" + build.buildNumber + " (ID: " + build.id + ")");
console.log("URL: " + build._links.web.href);
});
Run a Pipeline (YAML Pipelines)
function runPipeline(client, project, pipelineId, params, callback) {
var body = {
resources: {
repositories: {
self: {
refName: params.branch || "refs/heads/main"
}
}
},
templateParameters: params.templateParameters || {}
};
client.post("/" + project + "/_apis/pipelines/" + pipelineId + "/runs?api-version=7.1", body, callback);
}
Git Repositories API
List Repositories
client.get("/your-project/_apis/git/repositories?api-version=7.1", function (err, data) {
data.value.forEach(function (repo) {
console.log(repo.name + " — " + repo.defaultBranch + " — " + repo.size + " bytes");
});
});
Create a Pull Request
function createPullRequest(client, project, repoId, params, callback) {
var body = {
sourceRefName: "refs/heads/" + params.sourceBranch,
targetRefName: "refs/heads/" + (params.targetBranch || "main"),
title: params.title,
description: params.description || "",
reviewers: (params.reviewers || []).map(function (id) { return { id: id }; })
};
client.post("/" + project + "/_apis/git/repositories/" + repoId +
"/pullrequests?api-version=7.1", body, callback);
}
createPullRequest(client, "your-project", "repo-guid", {
sourceBranch: "feature/new-api",
targetBranch: "main",
title: "Add new REST API endpoints",
description: "Implements GET/POST/PUT for user profiles.\n\nCloses AB#1234"
}, function (err, pr) {
if (err) { return console.error(err); }
console.log("Created PR #" + pr.pullRequestId + ": " + pr.title);
});
Pagination
Most list APIs return paginated results. The response includes a continuationToken header when more results are available:
function getAllPages(client, path, callback) {
var allItems = [];
function fetchPage(continuationToken) {
var separator = path.indexOf("?") === -1 ? "?" : "&";
var url = path;
if (continuationToken) {
url += separator + "continuationToken=" + continuationToken;
}
client.request("GET", url, null, function (err, data, headers) {
if (err) { return callback(err); }
var items = data.value || data;
allItems = allItems.concat(items);
var nextToken = headers["x-ms-continuationtoken"];
if (nextToken) {
fetchPage(nextToken);
} else {
callback(null, allItems);
}
});
}
fetchPage(null);
}
// Get ALL builds (paginated)
getAllPages(client, "/your-project/_apis/build/builds?api-version=7.1", function (err, builds) {
console.log("Total builds: " + builds.length);
});
Rate Limiting
Azure DevOps enforces rate limits on a per-user, per-organization basis. The limits are generous — typically around 800 requests per minute — but automated scripts can hit them.
Response headers tell you the current state:
X-RateLimit-Limit: 800
X-RateLimit-Remaining: 743
X-RateLimit-Reset: 2024-01-15T14:30:00Z
Retry-After: 30
Implement retry logic:
function requestWithRetry(client, method, path, body, maxRetries, callback) {
var attempts = 0;
function attempt() {
attempts++;
client.request(method, path, body, function (err, data, headers) {
if (err && err.statusCode === 429 && attempts < maxRetries) {
var retryAfter = parseInt(headers["retry-after"] || "30", 10);
console.log("Rate limited. Retrying in " + retryAfter + "s (attempt " + attempts + "/" + maxRetries + ")");
setTimeout(attempt, retryAfter * 1000);
return;
}
callback(err, data, headers);
});
}
attempt();
}
Complete Working Example: Project Health Report Generator
This script generates a comprehensive project health report by pulling data from multiple API areas:
// project-health-report.js
var apiClient = require("./api-client");
var ORG = process.env.AZURE_ORG;
var PROJECT = process.env.AZURE_PROJECT;
var PAT = process.env.AZURE_PAT;
var client = apiClient.createClient(ORG, PAT);
var report = {
generated: new Date().toISOString(),
project: PROJECT,
builds: {},
workItems: {},
pullRequests: {},
repos: {}
};
var pendingRequests = 0;
function trackRequest() { pendingRequests++; }
function completeRequest() {
pendingRequests--;
if (pendingRequests === 0) { printReport(); }
}
// --- Builds ---
trackRequest();
client.get("/" + PROJECT + "/_apis/build/builds?$top=100&api-version=7.1", function (err, data) {
if (err) { console.error("Builds error:", err.message); return completeRequest(); }
var builds = data.value;
var last30Days = builds.filter(function (b) {
return new Date(b.startTime) > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
});
report.builds = {
total: last30Days.length,
succeeded: last30Days.filter(function (b) { return b.result === "succeeded"; }).length,
failed: last30Days.filter(function (b) { return b.result === "failed"; }).length,
canceled: last30Days.filter(function (b) { return b.result === "canceled"; }).length,
successRate: 0
};
if (report.builds.total > 0) {
report.builds.successRate = Math.round((report.builds.succeeded / report.builds.total) * 100);
}
completeRequest();
});
// --- Work Items ---
trackRequest();
client.post("/" + PROJECT + "/_apis/wit/wiql?api-version=7.1", {
query: "SELECT [System.Id] FROM WorkItems " +
"WHERE [System.WorkItemType] IN ('Bug', 'User Story') " +
"AND [System.State] <> 'Removed' " +
"AND [System.ChangedDate] >= @today - 30"
}, function (err, data) {
if (err) { console.error("Work items error:", err.message); return completeRequest(); }
var ids = data.workItems.map(function (wi) { return wi.id; });
if (ids.length === 0) {
report.workItems = { total: 0, bugs: 0, stories: 0 };
return completeRequest();
}
var batchIds = ids.slice(0, 200).join(",");
client.get("/" + PROJECT + "/_apis/wit/workitems?ids=" + batchIds + "&api-version=7.1", function (err2, items) {
if (err2) { console.error("Work item details error:", err2.message); return completeRequest(); }
var bugs = items.value.filter(function (i) { return i.fields["System.WorkItemType"] === "Bug"; });
var stories = items.value.filter(function (i) { return i.fields["System.WorkItemType"] === "User Story"; });
report.workItems = {
total: items.value.length,
bugs: bugs.length,
stories: stories.length,
activeBugs: bugs.filter(function (b) { return b.fields["System.State"] === "Active"; }).length,
closedBugs: bugs.filter(function (b) { return b.fields["System.State"] === "Closed"; }).length
};
completeRequest();
});
});
// --- Pull Requests ---
trackRequest();
client.get("/" + PROJECT + "/_apis/git/pullrequests?searchCriteria.status=all&$top=100&api-version=7.1", function (err, data) {
if (err) { console.error("PR error:", err.message); return completeRequest(); }
var prs = data.value;
var last30Days = prs.filter(function (pr) {
return new Date(pr.creationDate) > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
});
report.pullRequests = {
total: last30Days.length,
active: last30Days.filter(function (pr) { return pr.status === "active"; }).length,
completed: last30Days.filter(function (pr) { return pr.status === "completed"; }).length,
abandoned: last30Days.filter(function (pr) { return pr.status === "abandoned"; }).length
};
completeRequest();
});
// --- Repositories ---
trackRequest();
client.get("/" + PROJECT + "/_apis/git/repositories?api-version=7.1", function (err, data) {
if (err) { console.error("Repos error:", err.message); return completeRequest(); }
report.repos = {
count: data.value.length,
list: data.value.map(function (repo) {
return { name: repo.name, defaultBranch: repo.defaultBranch, sizeMB: Math.round(repo.size / 1024 / 1024) };
})
};
completeRequest();
});
function printReport() {
console.log("\n====================================");
console.log(" PROJECT HEALTH REPORT");
console.log(" " + report.project + " — " + report.generated);
console.log("====================================\n");
console.log("BUILDS (Last 30 days)");
console.log(" Total: " + report.builds.total);
console.log(" Succeeded: " + report.builds.succeeded);
console.log(" Failed: " + report.builds.failed);
console.log(" Success Rate: " + report.builds.successRate + "%\n");
console.log("WORK ITEMS (Last 30 days)");
console.log(" Total active: " + report.workItems.total);
console.log(" Bugs: " + report.workItems.bugs + " (" + report.workItems.activeBugs + " active)");
console.log(" Stories: " + report.workItems.stories + "\n");
console.log("PULL REQUESTS (Last 30 days)");
console.log(" Total: " + report.pullRequests.total);
console.log(" Active: " + report.pullRequests.active);
console.log(" Completed: " + report.pullRequests.completed);
console.log(" Abandoned: " + report.pullRequests.abandoned + "\n");
console.log("REPOSITORIES");
console.log(" Count: " + report.repos.count);
report.repos.list.forEach(function (repo) {
console.log(" - " + repo.name + " (" + repo.defaultBranch + ", " + repo.sizeMB + " MB)");
});
console.log("\n====================================");
}
Run it:
AZURE_ORG=your-org AZURE_PROJECT=your-project AZURE_PAT=your-pat node project-health-report.js
Common Issues and Troubleshooting
401 Unauthorized with a valid PAT
{"$id":"1","innerException":null,"message":"TF400813: The user '' is not authorized to access this resource."}
The PAT may have expired, or it was created for a different organization. PATs are scoped to a single organization. Verify the organization name in your URL matches the one selected when creating the PAT. Also check that the PAT has the required scopes — vso.build for builds, vso.work for work items, etc.
WIQL query returns empty results despite existing work items
{"queryType":"flat","queryResultType":"workItem","asOf":"...","columns":[],"workItems":[]}
WIQL is case-sensitive for field names. System.WorkItemType works but system.workitemtype does not. Also verify the area path and iteration path in WHERE clauses match exactly — trailing slashes or different casing will exclude results.
Patch request fails with "The request body content type is not supported"
HTTP 415: The request body content type 'application/json' is not supported
Work item update APIs require Content-Type: application/json-patch+json, not application/json. This is a common mistake because every other API endpoint uses standard JSON. The patch document is an array of JSON Patch operations, not a regular JSON object.
API returns "203 Non-Authoritative Information" instead of data
HTTP 203 with HTML login page content
This means your authentication failed but Azure DevOps returned a redirect to the login page instead of a 401. It happens with malformed authorization headers. Verify the Base64 encoding of your PAT includes the leading colon — Buffer.from(":YOUR_PAT"), not Buffer.from("YOUR_PAT").
Best Practices
Always specify api-version in requests. Without it, you get the oldest API version, which may return different field names, missing properties, or different response structures. Pin to a specific version and update deliberately.
Use service accounts for automated PATs. Personal PATs expire and break when the person leaves. Create a dedicated service account, grant it the minimum permissions, and use its PAT for automation scripts.
Batch work item fetches. WIQL returns only IDs. Fetch full work item details with the batch endpoint (
ids=1,2,3,4,5) rather than individual requests. The batch endpoint supports up to 200 IDs per request.Handle continuation tokens for all list endpoints. Never assume a single request returns all results. Even if you set
$top=1000, the API may paginate. Always check for thex-ms-continuationtokenresponse header.Cache responses where appropriate. Build definitions, project metadata, and team information change infrequently. Cache these with a TTL to reduce API calls and avoid rate limits.
Use the correct host for each API area. Release management APIs are on
vsrm.dev.azure.com, notdev.azure.com. Artifact feed APIs are onfeeds.dev.azure.com. Using the wrong host returns 404 errors that look like the endpoint does not exist.