ServiceNow Scripted REST APIs: Complete Developer Guide
ServiceNow Scripted REST APIs: Complete Developer Guide
ServiceNow's Scripted REST API framework lets you build custom REST endpoints inside the Now Platform. Unlike REST Messages (which are for outbound calls), Scripted REST APIs turn your ServiceNow instance into an API server — receiving and responding to requests from any HTTP client.
Most developers learn the basics and stop there. That leaves production systems fragile: no proper error codes, no authentication, no pagination, and performance that craters under load. This guide covers the patterns that separate hobby projects from hardened, production-grade APIs.
Creating Your First Scripted REST API
Navigate to System Web Services > Scripted REST APIs and create a new record:
Name: Incident Management API
Namespace: x_mycompany_incident
Base path: /incident
Then add a resource. The resource defines a method + path combination:
Name: List Open Incidents
HTTP Method: GET
Path: /open
Here's the script that handles it:
(function process(/*RESTRequest*/ request, /*RESTResponse*/ response) {
var gr = new GlideRecord('incident');
gr.addQuery('active', true);
gr.addQuery('state', '!=', 7); // not resolved/closed
gr.orderByDesc('opened_at');
gr.setLimit(100);
gr.query();
var results = [];
while (gr.next()) {
results.push({
sys_id: gr.sys_id.toString(),
number: gr.number.toString(),
short_description: gr.short_description.toString(),
state: gr.state.getDisplayValue(),
assigned_to: gr.assigned_to.getDisplayValue(),
opened_at: gr.opened_at.getValue()
});
}
response.setStatus(200);
return results;
})(request, response);
That works. But production APIs need more.
Authentication Patterns
Never leave an API unauthenticated unless it's intentionally public. Here are the three patterns you'll use most.
Basic Authentication
(function process(request, response) {
var auth = request.getHeader('Authorization');
if (!auth || !auth.startsWith('Basic ')) {
response.setStatus(401);
response.setHeader('WWW-Authenticate', 'Basic realm="ServiceNow"');
return { error: 'Authentication required' };
}
var decoded = new GlideEncdec().base64Decode(auth.slice(6));
var parts = decoded.split(':', 2);
var username = parts[0];
var password = parts[1];
// Validate against ServiceNow users
var gr = new GlideRecord('sys_user');
gr.addQuery('user_name', username);
gr.addQuery('active', true);
gr.query();
if (!gr.next()) {
response.setStatus(401);
return { error: 'Invalid credentials' };
}
// Continue with request processing...
})(request, response);
Note: Always use HTTPS. Basic auth over plain HTTP sends credentials in clear text.
API Key Authentication
For machine-to-machine integrations where username/password is impractical:
(function process(request, response) {
var apiKey = request.getHeader('x-api-key');
if (!apiKey) {
response.setStatus(401);
return { error: 'API key required. Pass x-api-key header.' };
}
var gr = new GlideRecord('api_key');
gr.addQuery('key', apiKey);
gr.addQuery('active', true);
gr.query();
if (!gr.next()) {
response.setStatus(401);
return { error: 'Invalid API key' };
}
// Store the key record for potential rate limiting or logging
request.apiKeyRecord = gr;
})(request, response);
Store your API keys in a dedicated api_key table with fields for the key value, associated user/service, active flag, and optional rate limits.
OAuth 2.0 Bearer Token
For more sophisticated auth flows, validate OAuth access tokens:
(function process(request, response) {
var authHeader = request.getHeader('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
response.setStatus(401);
return { error: 'Bearer token required' };
}
var token = authHeader.slice(7);
var tokenRecord = new GlideRecord('oauth_token');
tokenRecord.addQuery('token', token);
tokenRecord.addQuery('active', true);
tokenRecord.addQuery('sys_created_on', '>', gs.daysAgo(1));
tokenRecord.query();
if (!tokenRecord.next()) {
response.setStatus(401);
return { error: 'Invalid or expired token' };
}
})(request, response);
Handling POST and PUT with Request Body Parsing
For methods that receive JSON payloads:
function handlePost(request, response) {
var payload = request.body;
if (!payload) {
response.setStatus(400);
return { error: 'Request body required' };
}
var data;
try {
data = JSON.parse(payload);
} catch (e) {
response.setStatus(400);
return { error: 'Invalid JSON: ' + e.message };
}
// Validate required fields
var required = ['short_description', 'caller_id'];
for (var i = 0; i < required.length; i++) {
if (!data[required[i]]) {
response.setStatus(400);
return { error: 'Missing required field: ' + required[i] };
}
}
var gr = new GlideRecord('incident');
gr.initialize();
gr.short_description = data.short_description;
gr.caller_id = data.caller_id;
gr.urgency = data.urgency || 3;
gr.impact = data.impact || 2;
gr.insert();
response.setStatus(201);
return {
sys_id: gr.sys_id.toString(),
number: gr.number.toString(),
message: 'Incident created successfully'
};
}
Always validate before inserting. Never trust the incoming payload — malicious or malformed data is a real-world threat.
Pagination: The Right Way
Returning unbounded result sets is a performance killer. Here's cursor-based pagination using sys_id:
function listIncidents(request, response) {
var limit = parseInt(request.queryParams.limit) || 20;
var cursor = request.queryParams.cursor; // last seen sys_id
var gr = new GlideRecord('incident');
gr.addQuery('active', true);
if (cursor) {
gr.addQuery('sys_id', '>', cursor);
}
gr.orderBy('sys_id');
gr.setLimit(limit + 1); // fetch one extra to check if there's a next page
gr.query();
var results = [];
var nextCursor = null;
var count = 0;
while (gr.next()) {
count++;
if (count <= limit) {
results.push({
sys_id: gr.sys_id.toString(),
number: gr.number.toString(),
short_description: gr.short_description.toString()
});
nextCursor = gr.sys_id.toString();
}
}
response.setStatus(200);
response.setHeader('X-Has-Next-Page', (count > limit).toString());
return {
data: results,
pagination: {
next_cursor: (count > limit) ? nextCursor : null,
limit: limit
}
};
}
Clients consume this by storing the next_cursor and passing it back as the cursor query param on the next request. This is far more efficient than offset-based pagination on large tables.
Error Handling and HTTP Status Codes
Return the right status code so clients can handle errors programmatically:
| Scenario | Status Code |
|---|---|
| Success | 200 / 201 / 204 |
| Validation error | 400 |
| Authentication failed | 401 |
| Forbidden / insufficient permissions | 403 |
| Resource not found | 404 |
| Conflict (duplicate) | 409 |
| Rate limited | 429 |
| Server error | 500 |
function safeQuery(request, response) {
try {
var gr = new GlideRecord('incident');
gr.addQuery('sys_id', request.pathParams.sys_id);
gr.query();
if (!gr.next()) {
response.setStatus(404);
return { error: 'Incident not found', sys_id: request.pathParams.sys_id };
}
response.setStatus(200);
return { /* incident data */ };
} catch (e) {
gs.error('Incident API error: ' + e.message);
response.setStatus(500);
return { error: 'Internal server error', reference: gs.generateGUID() };
}
}
Always generate a reference ID for 500 errors. When clients report an issue, that ID lets you grep your logs instantly.
Performance Tips
A few things that make a massive difference under load:
1. Only query fields you need.
gr.selectFields(['sys_id', 'number', 'short_description', 'state']);
2. Cache reference field display values — don't call .getDisplayValue() in a loop.
var gr = new GlideRecord('incident');
// ... query ...
var grRef = new GlideRecord('sys_user');
while (gr.next()) {
// One query per row = N+1 problem
// gr.assigned_to.getDisplayValue(); // avoid inside loop
}
// Instead, batch-load reference records before the loop
3. Use GlideAggregate for counts:
var ga = new GlideAggregate('incident');
ga.addQuery('active', true);
ga.addAggregate('COUNT');
ga.query();
ga.next();
var total = ga.getAggregate('COUNT');
4. Set query timeouts on expensive endpoints:
gr.setLimit(100);
gr.query();
gs.sleep(0); // yield to prevent instance watchdog kills on long queries
Versioning Your API
As your API evolves, maintain backward compatibility by versioning:
Base path: /incident/v1
Base path: /incident/v2
Keep the old version running until all consumers migrate. Document the deprecation timeline in your API response headers:
response.setHeader('Deprecation', 'true');
response.setHeader('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
response.setHeader('Link', '</incident/v2/open>; rel="successor-version"');
Testing Your Scripted REST API
Use an HTTP client to test. In ServiceNow, the REST API Explorer (under System Web Services > REST API Explorer) generates test calls with proper auth. For local testing, curl works:
# GET with Basic auth
curl -X GET "https://yourinstance.service-now.com/api/x_mycompany_incident/incident/open" \
-u admin:password \
-H "Accept: application/json"
# POST with API key
curl -X POST "https://yourinstance.service-now.com/api/x_mycompany_incident/incident" \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"short_description": "Test incident", "caller_id": "681ccaf9c0a8016400b98b0686d73a2f"}'
Closing Thoughts
Scripted REST APIs are one of ServiceNow's most powerful integration features. The basics are easy to learn, but the details — authentication, error handling, pagination, performance — are what separate a proof-of-concept from a system that other teams trust with their critical integrations.
Start with the patterns above: auth first, proper error codes, pagination on everything that returns lists, and query limits on everything. Build those in from day one rather than bolting them on later.
Your future self — and the teams integrating with your API — will thank you.
