ServiceNow Integration with Azure DevOps
Integrate ServiceNow with Azure DevOps for change management gates, incident sync, and automated deployment tracking
ServiceNow Integration with Azure DevOps
Connecting ServiceNow to Azure DevOps is one of the highest-value integrations you can build for an enterprise engineering team. It bridges the gap between ITSM processes and modern DevOps workflows, letting you enforce change management gates, sync incidents to work items, and maintain a clear audit trail from code commit to production deployment. If your organization runs ServiceNow for ITSM and Azure DevOps for CI/CD, you need this integration -- and you need it built correctly.
Prerequisites
Before you start, make sure you have the following in place:
- ServiceNow instance (Paris release or later) with admin or integration-role access
- Azure DevOps organization with a project and at least one pipeline configured
- ServiceNow REST API credentials -- a dedicated integration user with the
itil,rest_service, andchange_managerroles - Azure DevOps Personal Access Token (PAT) with Work Items (Read/Write), Build (Read), and Release (Read/Write) scopes
- Node.js v16+ installed locally for building the middleware service
- Basic familiarity with ServiceNow's Table API and Azure DevOps REST API
- An understanding of ITIL change management workflows (Normal, Standard, Emergency)
Why Integrate ServiceNow with Azure DevOps
Most enterprise environments have two separate worlds: the development team living in Azure DevOps (boards, repos, pipelines) and the operations team living in ServiceNow (incidents, changes, CIs). Without integration, these worlds communicate through emails, Slack messages, and manual copy-paste. That approach does not scale.
Here is what you get from a proper integration:
- Automated change requests -- Every production deployment creates a ServiceNow change request automatically. No more spreadsheets.
- Approval gates -- Pipelines pause and wait for ServiceNow change approval before deploying to production.
- Incident-to-work-item sync -- Production incidents in ServiceNow create Azure DevOps work items so developers see them in their sprint backlog.
- Audit trail -- Compliance teams can trace any production change back through the approval chain, pipeline run, commit, and work item.
- Reduced lead time -- Automated workflows cut days off the change management process.
Change Management Gate in Pipelines
The most common integration point is the change management gate. In a typical enterprise workflow, no code reaches production without an approved change request. You can enforce this directly in your Azure DevOps YAML pipeline.
The ServiceNow Change Management extension for Azure DevOps provides pipeline tasks, but many teams prefer a custom approach for more control. Here is a pipeline snippet that calls a middleware service to validate change approval:
stages:
- stage: ProductionDeploy
condition: succeeded()
jobs:
- job: ValidateChange
pool:
vmImage: 'ubuntu-latest'
steps:
- task: InvokeRestAPI@1
displayName: 'Check ServiceNow Change Approval'
inputs:
connectionType: 'connectedServiceName'
serviceConnection: 'ServiceNow-Middleware'
method: 'POST'
urlSuffix: '/api/change/validate'
body: |
{
"pipelineId": "$(System.DefinitionId)",
"buildNumber": "$(Build.BuildNumber)",
"environment": "production"
}
waitForCompletion: 'true'
- deployment: Deploy
dependsOn: ValidateChange
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to production"
The waitForCompletion setting is critical. The pipeline pauses until the middleware confirms the change request is approved in ServiceNow. If the change is rejected, the pipeline fails.
ServiceNow Change Request Creation from Pipelines
Rather than waiting for someone to manually create a change request, your pipeline can create one automatically when a build is ready for production. Here is the Node.js code to create a change request via the ServiceNow Table API:
var axios = require('axios');
function createChangeRequest(config, pipelineData) {
var url = config.servicenowUrl + '/api/now/table/change_request';
var changePayload = {
type: 'standard',
short_description: 'Deployment: ' + pipelineData.buildNumber + ' - ' + pipelineData.projectName,
description: 'Automated change request for Azure DevOps pipeline deployment.\n\n'
+ 'Pipeline: ' + pipelineData.pipelineName + '\n'
+ 'Build: ' + pipelineData.buildNumber + '\n'
+ 'Repository: ' + pipelineData.repoName + '\n'
+ 'Commit: ' + pipelineData.commitHash + '\n'
+ 'Requested by: ' + pipelineData.requestedBy,
category: 'Software',
priority: '3',
risk: 'moderate',
impact: '2',
assignment_group: config.changeAssignmentGroup,
cmdb_ci: pipelineData.configItemId,
start_date: new Date().toISOString(),
end_date: new Date(Date.now() + 3600000).toISOString(),
u_azure_devops_build: pipelineData.buildNumber,
u_azure_devops_project: pipelineData.projectName
};
return axios.post(url, changePayload, {
auth: {
username: config.servicenowUser,
password: config.servicenowPassword
},
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}).then(function(response) {
var changeNumber = response.data.result.number;
console.log('Created change request: ' + changeNumber);
return response.data.result;
}).catch(function(error) {
console.error('Failed to create change request:', error.response ? error.response.data : error.message);
throw error;
});
}
module.exports = { createChangeRequest: createChangeRequest };
Note the custom fields u_azure_devops_build and u_azure_devops_project. You will need to create these on the change_request table in ServiceNow. Custom fields in ServiceNow always start with u_.
Work Item Sync Between Systems
Bidirectional sync between Azure DevOps work items and ServiceNow records is where things get interesting -- and complicated. The key decision is: which system is the source of truth for which data?
My recommendation: Azure DevOps owns development work items (user stories, tasks, bugs). ServiceNow owns incidents and change requests. The integration syncs relevant data between them without duplicating ownership.
Here is a function that creates an Azure DevOps work item from a ServiceNow incident:
var axios = require('axios');
function createWorkItemFromIncident(config, incident) {
var url = 'https://dev.azure.com/' + config.organization
+ '/' + config.project
+ '/_apis/wit/workitems/$Bug?api-version=7.0';
var patchDocument = [
{
op: 'add',
path: '/fields/System.Title',
value: '[INC' + incident.number + '] ' + incident.short_description
},
{
op: 'add',
path: '/fields/System.Description',
value: '<h3>ServiceNow Incident</h3>'
+ '<p><strong>Number:</strong> ' + incident.number + '</p>'
+ '<p><strong>Priority:</strong> ' + incident.priority + '</p>'
+ '<p><strong>Category:</strong> ' + incident.category + '</p>'
+ '<p>' + incident.description + '</p>'
},
{
op: 'add',
path: '/fields/Microsoft.VSTS.Common.Priority',
value: mapPriority(incident.priority)
},
{
op: 'add',
path: '/fields/System.Tags',
value: 'ServiceNow;Incident;' + incident.number
},
{
op: 'add',
path: '/fields/Custom.ServiceNowId',
value: incident.sys_id
}
];
var token = Buffer.from(':' + config.azureDevOpsPat).toString('base64');
return axios.post(url, patchDocument, {
headers: {
'Content-Type': 'application/json-patch+json',
'Authorization': 'Basic ' + token
}
}).then(function(response) {
console.log('Created work item #' + response.data.id + ' from incident ' + incident.number);
return response.data;
});
}
function mapPriority(snowPriority) {
var mapping = { '1': 1, '2': 1, '3': 2, '4': 3, '5': 4 };
return mapping[snowPriority] || 2;
}
module.exports = {
createWorkItemFromIncident: createWorkItemFromIncident,
mapPriority: mapPriority
};
The priority mapping is important. ServiceNow uses a 1-5 scale (1 is critical), and Azure DevOps uses 1-4 (1 is critical). You need to define this mapping explicitly.
Incident Management Integration
When a production incident is raised in ServiceNow, your integration should do three things:
- Create a bug work item in Azure DevOps (shown above)
- Link the work item back to the ServiceNow incident so updates flow both ways
- Update the ServiceNow incident when the work item state changes
For the third point, you need a webhook listener in your middleware service:
var express = require('express');
var axios = require('axios');
var router = express.Router();
router.post('/webhook/workitem-updated', function(req, res) {
var payload = req.body;
if (payload.eventType !== 'workitem.updated') {
return res.status(200).json({ message: 'Ignored' });
}
var workItem = payload.resource;
var servicenowId = workItem.fields['Custom.ServiceNowId'];
if (!servicenowId) {
return res.status(200).json({ message: 'No ServiceNow link' });
}
var stateMapping = {
'Active': '2', // In Progress
'Resolved': '6', // Resolved
'Closed': '7' // Closed
};
var newState = workItem.fields['System.State'];
var snowState = stateMapping[newState];
if (!snowState) {
return res.status(200).json({ message: 'State not mapped' });
}
var config = req.app.get('config');
var url = config.servicenowUrl + '/api/now/table/incident/' + servicenowId;
axios.patch(url, {
state: snowState,
work_notes: 'Azure DevOps work item #' + workItem.id + ' moved to state: ' + newState
}, {
auth: {
username: config.servicenowUser,
password: config.servicenowPassword
},
headers: { 'Content-Type': 'application/json' }
}).then(function() {
res.status(200).json({ message: 'Incident updated' });
}).catch(function(error) {
console.error('Failed to update incident:', error.message);
res.status(500).json({ error: 'Update failed' });
});
});
module.exports = router;
Approval Workflows
ServiceNow approvals can gate your Azure DevOps pipeline stages. The pattern works like this:
- Pipeline creates a change request in ServiceNow
- Pipeline enters a waiting state (server callback)
- ServiceNow sends approval/rejection via webhook to your middleware
- Middleware calls Azure DevOps pipeline API to resume or fail the stage
Here is the approval callback handler:
router.post('/webhook/snow-approval', function(req, res) {
var payload = req.body;
var changeNumber = payload.number;
var approvalStatus = payload.approval_status;
// Look up the pending pipeline run for this change
var pendingRun = getPendingRun(changeNumber);
if (!pendingRun) {
return res.status(404).json({ error: 'No pending pipeline for change ' + changeNumber });
}
var config = req.app.get('config');
if (approvalStatus === 'approved') {
resumePipeline(config, pendingRun).then(function() {
res.status(200).json({ message: 'Pipeline resumed' });
});
} else {
failPipeline(config, pendingRun, 'Change request ' + changeNumber + ' was rejected').then(function() {
res.status(200).json({ message: 'Pipeline cancelled' });
});
}
});
function resumePipeline(config, pendingRun) {
var url = pendingRun.callbackUrl;
var token = Buffer.from(':' + config.azureDevOpsPat).toString('base64');
return axios.post(url, {
result: 'succeeded'
}, {
headers: { 'Authorization': 'Basic ' + token }
});
}
function failPipeline(config, pendingRun, reason) {
var url = pendingRun.callbackUrl;
var token = Buffer.from(':' + config.azureDevOpsPat).toString('base64');
return axios.post(url, {
result: 'failed',
comment: reason
}, {
headers: { 'Authorization': 'Basic ' + token }
});
}
You will need a data store (Redis, database, or even an in-memory map for dev) to track the mapping between ServiceNow change numbers and Azure DevOps pipeline callback URLs.
REST API Integration with Node.js
Both ServiceNow and Azure DevOps expose comprehensive REST APIs. Here is a reusable client module that wraps both:
var axios = require('axios');
function ServiceNowClient(config) {
this.baseUrl = config.url;
this.auth = {
username: config.username,
password: config.password
};
}
ServiceNowClient.prototype.getTable = function(table, query, fields) {
var params = {
sysparm_query: query || '',
sysparm_fields: fields ? fields.join(',') : '',
sysparm_limit: 100
};
return axios.get(this.baseUrl + '/api/now/table/' + table, {
auth: this.auth,
params: params,
headers: { 'Accept': 'application/json' }
}).then(function(response) {
return response.data.result;
});
};
ServiceNowClient.prototype.updateRecord = function(table, sysId, data) {
return axios.patch(this.baseUrl + '/api/now/table/' + table + '/' + sysId, data, {
auth: this.auth,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}).then(function(response) {
return response.data.result;
});
};
ServiceNowClient.prototype.createRecord = function(table, data) {
return axios.post(this.baseUrl + '/api/now/table/' + table, data, {
auth: this.auth,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}).then(function(response) {
return response.data.result;
});
};
function AzureDevOpsClient(config) {
this.organization = config.organization;
this.project = config.project;
this.token = Buffer.from(':' + config.pat).toString('base64');
this.baseUrl = 'https://dev.azure.com/' + config.organization + '/' + config.project;
}
AzureDevOpsClient.prototype.getWorkItem = function(id) {
var url = this.baseUrl + '/_apis/wit/workitems/' + id + '?api-version=7.0&$expand=all';
return axios.get(url, {
headers: { 'Authorization': 'Basic ' + this.token }
}).then(function(response) {
return response.data;
});
};
AzureDevOpsClient.prototype.queryWorkItems = function(wiql) {
var url = this.baseUrl + '/_apis/wit/wiql?api-version=7.0';
return axios.post(url, { query: wiql }, {
headers: {
'Authorization': 'Basic ' + this.token,
'Content-Type': 'application/json'
}
}).then(function(response) {
return response.data.workItems;
});
};
module.exports = {
ServiceNowClient: ServiceNowClient,
AzureDevOpsClient: AzureDevOpsClient
};
Webhook-Based Bidirectional Sync
Polling is expensive and introduces latency. Both ServiceNow and Azure DevOps support webhooks. Set up webhooks on both sides:
Azure DevOps Service Hook -- Configure through Project Settings > Service hooks. Select "Web Hooks" and subscribe to:
- Work item created
- Work item updated
- Build completed
- Release deployment completed
ServiceNow Business Rule -- Create an async business rule on the incident and change_request tables that sends an HTTP POST when records are created or updated.
Here is the middleware that receives webhooks from both systems and routes them:
var express = require('express');
var crypto = require('crypto');
var app = express();
app.use(express.json({ limit: '5mb' }));
// Verify Azure DevOps webhook signature
function verifyAzureDevOpsSignature(req, secret) {
var signature = req.headers['x-hub-signature'];
if (!signature || !secret) return true; // skip if no secret configured
var hmac = crypto.createHmac('sha1', secret);
var digest = 'sha1=' + hmac.update(JSON.stringify(req.body)).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}
app.post('/webhook/azure-devops', function(req, res) {
var config = app.get('config');
if (!verifyAzureDevOpsSignature(req, config.webhookSecret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
var eventType = req.body.eventType;
console.log('Received Azure DevOps event: ' + eventType);
var handlers = {
'workitem.created': handleWorkItemCreated,
'workitem.updated': handleWorkItemUpdated,
'build.complete': handleBuildComplete,
'ms.vss-release.deployment-completed-event': handleDeploymentComplete
};
var handler = handlers[eventType];
if (handler) {
handler(req.body, config).then(function(result) {
res.status(200).json(result);
}).catch(function(error) {
console.error('Handler error:', error.message);
res.status(500).json({ error: error.message });
});
} else {
res.status(200).json({ message: 'Event type not handled' });
}
});
app.post('/webhook/servicenow', function(req, res) {
var payload = req.body;
var table = payload.table;
var action = payload.action;
console.log('Received ServiceNow event: ' + table + '/' + action);
if (table === 'incident' && action === 'insert') {
handleNewIncident(payload.record).then(function() {
res.status(200).json({ message: 'Processed' });
});
} else if (table === 'change_request' && action === 'update') {
handleChangeUpdate(payload.record).then(function() {
res.status(200).json({ message: 'Processed' });
});
} else {
res.status(200).json({ message: 'Not handled' });
}
});
Configuration Item Tracking
ServiceNow's CMDB (Configuration Management Database) is where your organization tracks infrastructure and application components. Your integration should link Azure DevOps pipelines to specific CIs so that change requests are automatically associated with the right application.
Create a mapping configuration:
var CI_MAPPING = {
'my-web-app': {
cmdbCi: 'abc123def456', // ServiceNow CI sys_id
assignmentGroup: 'Web Platform',
changeTemplate: 'standard_deploy',
environment: {
'dev': { risk: 'low', impact: '3' },
'staging': { risk: 'moderate', impact: '3' },
'production': { risk: 'moderate', impact: '2' }
}
},
'payment-service': {
cmdbCi: '789ghi012jkl',
assignmentGroup: 'Payment Engineering',
changeTemplate: 'normal',
environment: {
'dev': { risk: 'low', impact: '3' },
'staging': { risk: 'moderate', impact: '2' },
'production': { risk: 'high', impact: '1' }
}
}
};
function getCIConfig(repositoryName, environment) {
var config = CI_MAPPING[repositoryName];
if (!config) {
throw new Error('No CI mapping found for repository: ' + repositoryName);
}
var envConfig = config.environment[environment] || config.environment['production'];
return {
cmdbCi: config.cmdbCi,
assignmentGroup: config.assignmentGroup,
changeTemplate: config.changeTemplate,
risk: envConfig.risk,
impact: envConfig.impact
};
}
Store this mapping in a database or config file rather than hardcoding it. The example above shows the structure you need.
Deployment Tracking in ServiceNow
After a deployment completes, update the change request and optionally create a deployment record. This closes the loop -- every deployment is traceable in ServiceNow.
function recordDeployment(snowClient, changeNumber, deploymentData) {
// First, find the change request
return snowClient.getTable('change_request', 'number=' + changeNumber, ['sys_id', 'number', 'state'])
.then(function(results) {
if (results.length === 0) {
throw new Error('Change request not found: ' + changeNumber);
}
var changeSysId = results[0].sys_id;
// Update the change request to implement state
return snowClient.updateRecord('change_request', changeSysId, {
state: '-1', // Implement
work_notes: 'Deployment completed successfully.\n'
+ 'Build: ' + deploymentData.buildNumber + '\n'
+ 'Environment: ' + deploymentData.environment + '\n'
+ 'Duration: ' + deploymentData.duration + 's\n'
+ 'Deployed by: ' + deploymentData.requestedBy
});
})
.then(function() {
// Move to review state
return snowClient.getTable('change_request', 'number=' + changeNumber, ['sys_id'])
.then(function(results) {
return snowClient.updateRecord('change_request', results[0].sys_id, {
state: '0', // Review
close_code: 'successful',
close_notes: 'Automated deployment completed successfully'
});
});
});
}
Custom Pipeline Tasks for ServiceNow
If you want a reusable pipeline task instead of raw REST calls, create a custom Azure DevOps extension. The task definition in task.json:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "ServiceNowChangeGate",
"friendlyName": "ServiceNow Change Gate",
"description": "Creates and validates ServiceNow change requests",
"category": "Deploy",
"visibility": ["Release", "Build"],
"author": "Your Team",
"version": {
"Major": 1,
"Minor": 0,
"Patch": 0
},
"inputs": [
{
"name": "serviceNowConnection",
"type": "connectedService:ServiceNow",
"label": "ServiceNow Connection",
"required": true
},
{
"name": "changeType",
"type": "pickList",
"label": "Change Type",
"required": true,
"options": {
"standard": "Standard",
"normal": "Normal",
"emergency": "Emergency"
}
},
{
"name": "configurationItem",
"type": "string",
"label": "Configuration Item (sys_id)",
"required": true
}
],
"execution": {
"Node16": {
"target": "index.js"
}
}
}
The task runner (index.js) uses the azure-pipelines-task-lib:
var tl = require('azure-pipelines-task-lib/task');
var axios = require('axios');
function run() {
try {
var endpoint = tl.getInput('serviceNowConnection', true);
var endpointUrl = tl.getEndpointUrl(endpoint, false);
var auth = tl.getEndpointAuthorization(endpoint, false);
var changeType = tl.getInput('changeType', true);
var ciSysId = tl.getInput('configurationItem', true);
var buildNumber = tl.getVariable('Build.BuildNumber');
var projectName = tl.getVariable('System.TeamProject');
console.log('Creating ' + changeType + ' change request in ServiceNow...');
axios.post(endpointUrl + '/api/now/table/change_request', {
type: changeType,
short_description: 'Deploy ' + projectName + ' build ' + buildNumber,
cmdb_ci: ciSysId,
u_azure_devops_build: buildNumber
}, {
auth: {
username: auth.parameters.username,
password: auth.parameters.password
},
headers: { 'Content-Type': 'application/json' }
}).then(function(response) {
var changeNumber = response.data.result.number;
tl.setVariable('ServiceNow.ChangeNumber', changeNumber);
console.log('Created change request: ' + changeNumber);
tl.setResult(tl.TaskResult.Succeeded, 'Change request created: ' + changeNumber);
}).catch(function(error) {
tl.setResult(tl.TaskResult.Failed, 'Failed to create change: ' + error.message);
});
} catch (err) {
tl.setResult(tl.TaskResult.Failed, err.message);
}
}
run();
Audit and Compliance Reporting
For SOC 2, SOX, or HIPAA compliance, you need to prove that every production change went through an approval process. The integration provides this by linking:
- Azure DevOps commit to a work item
- Work item to a pipeline run
- Pipeline run to a ServiceNow change request
- Change request to an approval record
Build a reporting endpoint that assembles this chain:
function getAuditTrail(snowClient, adoClient, changeNumber) {
return snowClient.getTable('change_request', 'number=' + changeNumber, [
'sys_id', 'number', 'state', 'approval', 'opened_at', 'closed_at',
'u_azure_devops_build', 'u_azure_devops_project'
]).then(function(changes) {
if (changes.length === 0) {
throw new Error('Change not found');
}
var change = changes[0];
var trail = {
changeRequest: {
number: change.number,
state: change.state,
approval: change.approval,
openedAt: change.opened_at,
closedAt: change.closed_at
},
build: null,
workItems: [],
approvals: []
};
// Get approval records
var approvalPromise = snowClient.getTable('sysapproval_approver',
'sysapproval=' + change.sys_id,
['approver', 'state', 'sys_updated_on']
).then(function(approvals) {
trail.approvals = approvals.map(function(a) {
return {
approver: a.approver,
state: a.state,
date: a.sys_updated_on
};
});
});
// Get Azure DevOps build info
var buildPromise = change.u_azure_devops_build
? adoClient.queryWorkItems(
"SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS '"
+ change.u_azure_devops_build + "'"
).then(function(workItems) {
trail.workItems = workItems;
})
: Promise.resolve();
return Promise.all([approvalPromise, buildPromise]).then(function() {
return trail;
});
});
}
Complete Working Example
Here is a complete Node.js middleware service that ties everything together. This service handles work item sync, change request creation, and deployment tracking.
var express = require('express');
var bodyParser = require('body-parser');
var axios = require('axios');
var app = express();
app.use(bodyParser.json({ limit: '5mb' }));
// ---- Configuration ----
var config = {
servicenow: {
url: process.env.SERVICENOW_URL,
username: process.env.SERVICENOW_USER,
password: process.env.SERVICENOW_PASSWORD
},
azureDevOps: {
organization: process.env.ADO_ORG,
project: process.env.ADO_PROJECT,
pat: process.env.ADO_PAT
},
port: process.env.PORT || 3000,
webhookSecret: process.env.WEBHOOK_SECRET
};
// ---- ServiceNow Client ----
function SNClient(cfg) {
this.baseUrl = cfg.url;
this.auth = { username: cfg.username, password: cfg.password };
}
SNClient.prototype.request = function(method, table, sysId, data, query) {
var url = this.baseUrl + '/api/now/table/' + table;
if (sysId) url += '/' + sysId;
var opts = {
method: method,
url: url,
auth: this.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
params: query || {}
};
if (data) opts.data = data;
return axios(opts).then(function(res) { return res.data.result; });
};
SNClient.prototype.create = function(table, data) {
return this.request('post', table, null, data);
};
SNClient.prototype.update = function(table, sysId, data) {
return this.request('patch', table, sysId, data);
};
SNClient.prototype.query = function(table, query, fields) {
return this.request('get', table, null, null, {
sysparm_query: query,
sysparm_fields: fields ? fields.join(',') : '',
sysparm_limit: 50
});
};
// ---- Azure DevOps Client ----
function ADOClient(cfg) {
this.baseUrl = 'https://dev.azure.com/' + cfg.organization + '/' + cfg.project;
this.authHeader = 'Basic ' + Buffer.from(':' + cfg.pat).toString('base64');
}
ADOClient.prototype.createWorkItem = function(type, fields) {
var patchDoc = Object.keys(fields).map(function(path) {
return { op: 'add', path: '/fields/' + path, value: fields[path] };
});
return axios.post(
this.baseUrl + '/_apis/wit/workitems/$' + type + '?api-version=7.0',
patchDoc,
{ headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json-patch+json' } }
).then(function(res) { return res.data; });
};
ADOClient.prototype.getWorkItem = function(id) {
return axios.get(
this.baseUrl + '/_apis/wit/workitems/' + id + '?api-version=7.0&$expand=all',
{ headers: { 'Authorization': this.authHeader } }
).then(function(res) { return res.data; });
};
// ---- Initialize Clients ----
var snow = new SNClient(config.servicenow);
var ado = new ADOClient(config.azureDevOps);
// ---- Pending Runs Store (use Redis in production) ----
var pendingRuns = {};
// ---- Routes ----
// Health check
app.get('/health', function(req, res) {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Create change request for deployment
app.post('/api/change/create', function(req, res) {
var body = req.body;
snow.create('change_request', {
type: body.changeType || 'standard',
short_description: 'Deploy ' + body.project + ' build ' + body.buildNumber,
description: 'Automated deployment change request\n'
+ 'Pipeline: ' + body.pipelineName + '\n'
+ 'Build: ' + body.buildNumber + '\n'
+ 'Environment: ' + body.environment,
category: 'Software',
priority: body.environment === 'production' ? '2' : '3',
assignment_group: body.assignmentGroup,
cmdb_ci: body.configItemId,
u_azure_devops_build: body.buildNumber,
u_azure_devops_project: body.project
}).then(function(result) {
// Store the pending run for callback
if (body.callbackUrl) {
pendingRuns[result.number] = {
callbackUrl: body.callbackUrl,
buildNumber: body.buildNumber,
createdAt: new Date()
};
}
res.json({
changeNumber: result.number,
sysId: result.sys_id,
state: result.state
});
}).catch(function(err) {
console.error('Change creation failed:', err.message);
res.status(500).json({ error: err.message });
});
});
// Validate change approval (called by pipeline gate)
app.post('/api/change/validate', function(req, res) {
var changeNumber = req.body.changeNumber;
snow.query('change_request', 'number=' + changeNumber, ['state', 'approval', 'number'])
.then(function(results) {
if (results.length === 0) {
return res.status(404).json({ error: 'Change not found' });
}
var change = results[0];
var approved = change.approval === 'approved';
res.json({
changeNumber: change.number,
approved: approved,
state: change.state
});
}).catch(function(err) {
res.status(500).json({ error: err.message });
});
});
// Webhook: ServiceNow incident created -> Azure DevOps bug
app.post('/webhook/servicenow/incident', function(req, res) {
var incident = req.body;
var priorityMap = { '1': 1, '2': 1, '3': 2, '4': 3, '5': 4 };
ado.createWorkItem('Bug', {
'System.Title': '[' + incident.number + '] ' + incident.short_description,
'System.Description': incident.description,
'Microsoft.VSTS.Common.Priority': priorityMap[incident.priority] || 2,
'System.Tags': 'ServiceNow;Incident;' + incident.number,
'Custom.ServiceNowId': incident.sys_id
}).then(function(workItem) {
// Update ServiceNow incident with work item link
return snow.update('incident', incident.sys_id, {
work_notes: 'Azure DevOps Bug #' + workItem.id + ' created.\n'
+ 'Link: ' + config.azureDevOps.organization + '/' + config.azureDevOps.project
+ '/_workitems/edit/' + workItem.id
}).then(function() {
res.json({ workItemId: workItem.id });
});
}).catch(function(err) {
console.error('Incident sync failed:', err.message);
res.status(500).json({ error: err.message });
});
});
// Webhook: Azure DevOps work item updated -> ServiceNow incident
app.post('/webhook/ado/workitem', function(req, res) {
var payload = req.body;
if (payload.eventType !== 'workitem.updated') {
return res.status(200).json({ message: 'Ignored' });
}
var fields = payload.resource.fields;
var snowId = fields['Custom.ServiceNowId'];
if (!snowId) {
return res.status(200).json({ message: 'No ServiceNow link' });
}
var stateMap = { 'Active': '2', 'Resolved': '6', 'Closed': '7' };
var newState = fields['System.State'];
var snowState = stateMap[newState];
if (!snowState) {
return res.status(200).json({ message: 'State not mapped' });
}
snow.update('incident', snowId, {
state: snowState,
work_notes: 'Work item state changed to: ' + newState
}).then(function() {
res.json({ message: 'Updated' });
}).catch(function(err) {
res.status(500).json({ error: err.message });
});
});
// Webhook: ServiceNow change approved/rejected -> resume/fail pipeline
app.post('/webhook/servicenow/change-approval', function(req, res) {
var payload = req.body;
var changeNumber = payload.number;
var pending = pendingRuns[changeNumber];
if (!pending) {
return res.status(404).json({ error: 'No pending pipeline' });
}
var result = payload.approval === 'approved' ? 'succeeded' : 'failed';
axios.post(pending.callbackUrl, {
result: result,
comment: result === 'failed' ? 'Change ' + changeNumber + ' rejected' : ''
}, {
headers: { 'Authorization': 'Basic ' + Buffer.from(':' + config.azureDevOps.pat).toString('base64') }
}).then(function() {
delete pendingRuns[changeNumber];
res.json({ message: 'Pipeline ' + result });
}).catch(function(err) {
res.status(500).json({ error: err.message });
});
});
// Deployment complete - update change request
app.post('/api/deployment/complete', function(req, res) {
var body = req.body;
snow.query('change_request', 'number=' + body.changeNumber, ['sys_id'])
.then(function(results) {
if (results.length === 0) {
throw new Error('Change not found');
}
return snow.update('change_request', results[0].sys_id, {
state: '0', // Review/Close
close_code: body.success ? 'successful' : 'unsuccessful',
close_notes: 'Deployment ' + (body.success ? 'succeeded' : 'failed') + '.\n'
+ 'Build: ' + body.buildNumber + '\n'
+ 'Duration: ' + body.duration + 's\n'
+ 'Environment: ' + body.environment
});
}).then(function() {
res.json({ message: 'Change request closed' });
}).catch(function(err) {
res.status(500).json({ error: err.message });
});
});
// ---- Start Server ----
app.listen(config.port, function() {
console.log('ServiceNow-ADO middleware running on port ' + config.port);
});
Save this as server.js and run with:
npm init -y
npm install express axios body-parser
SERVICENOW_URL=https://yourinstance.service-now.com \
SERVICENOW_USER=integration_user \
SERVICENOW_PASSWORD=secret \
ADO_ORG=your-org \
ADO_PROJECT=your-project \
ADO_PAT=your-pat \
node server.js
Common Issues and Troubleshooting
1. ServiceNow API returns 403 Forbidden
This almost always means your integration user is missing a required role. The rest_service role is not enough -- you also need itil for creating incidents and change requests, and change_manager for modifying change states. Check the user's roles in ServiceNow under System Security > Users.
2. Azure DevOps webhook payloads arrive empty or malformed
Azure DevOps service hooks send different payload structures depending on the event type. The resource field structure varies between work item events and build events. Always log the raw payload during development. Also verify your webhook URL is accessible from the internet -- Azure DevOps sends webhooks from Microsoft-hosted infrastructure, not your network.
3. Change request state transitions fail with "Invalid state transition"
ServiceNow enforces a state machine for change requests. You cannot jump from "New" directly to "Closed." The valid sequence is typically: New (-5) -> Assess (-4) -> Authorize (-3) -> Scheduled (-2) -> Implement (-1) -> Review (0) -> Closed (3). If your automation skips states, ServiceNow will reject the update. Configure your ServiceNow instance to allow the transitions your automation needs, or update your code to walk through each state.
4. Duplicate work items created from the same ServiceNow incident
Without idempotency checks, retried webhooks create duplicate records. Always check for existing work items before creating new ones. Query Azure DevOps with a WIQL query like SELECT [System.Id] FROM WorkItems WHERE [Custom.ServiceNowId] = 'the-sys-id' before creating. On the ServiceNow side, check for u_azure_devops_build before creating duplicate change requests.
5. Token expiration and authentication failures mid-sync
Azure DevOps PATs expire. ServiceNow passwords get rotated. Build retry logic with exponential backoff and alert on authentication failures. Use a secret manager (Azure Key Vault, HashiCorp Vault) rather than environment variables in production. Set up monitoring that catches 401 responses before your entire pipeline flow breaks.
6. Rate limiting on ServiceNow Table API
ServiceNow enforces rate limits, especially on shared developer instances. If you see 429 responses, implement request queuing with a delay between calls. Batch operations where possible -- use the ServiceNow Batch API to send multiple requests in a single HTTP call.
Best Practices
Use a dedicated integration user on both platforms. Never use a personal account for automation. The integration user should have exactly the roles it needs -- no more.
Implement idempotency everywhere. Webhooks can fire multiple times. Use the ServiceNow
sys_idor Azure DevOps work item ID as a deduplication key. Store processed event IDs and skip duplicates.Log every API call with request and response details (redact credentials). When an audit asks why a deployment happened, you need the receipts.
Use standard change templates for routine deployments. Standard changes in ServiceNow are pre-approved, which means your pipeline does not have to wait for manual approval on every deploy. Reserve normal changes for significant releases.
Build a circuit breaker between systems. If ServiceNow is down, your pipeline should not be permanently blocked. Implement a timeout with a fallback approval process (e.g., manual approval in Azure DevOps if ServiceNow is unreachable for more than 15 minutes).
Version your integration configuration. The CI-to-pipeline mapping, priority mappings, and state mappings should be in version control, not hardcoded in ServiceNow business rules.
Test with ServiceNow's developer instance. ServiceNow provides free developer instances at developer.servicenow.com. Use one for integration testing. Never test against production.
Monitor the middleware service. Track API response times, error rates, and sync lag. Set up alerts when the sync falls behind. A broken integration that nobody notices is worse than no integration at all.
Handle partial failures gracefully. If the change request is created but the work item link fails, do not roll back the change request. Log the failure, create an alert, and let a human fix it. Distributed transactions across two SaaS platforms are not worth the complexity.