Scripted REST API Best Practices in ServiceNow

A comprehensive guide consolidating industry best practices for designing, implementing, securing, and maintaining Scripted REST APIs in ServiceNow.


Table of Contents

  1. When to Use Scripted REST APIs
  2. Authentication & Security
  3. Authorization with ACLs
  4. API Design Principles
  5. Idempotency
  6. Versioning
  7. Error Handling
  8. Transaction Management
  9. Performance Optimization
  10. Input Validation
  11. Logging & Debugging
  12. Documentation
  13. Testing
  14. Code Architecture
  15. Complete Endpoint Template
  16. Common Pitfalls to Avoid

When to Use Scripted REST APIs

Use Scripted REST APIs when you need:

Don't use Scripted REST APIs when:


Authentication & Security

Implement defense-in-depth security measures.

Reference: OWASP API Security Top 10 (opens in a new tab)

Prefer OAuth Over Basic Authentication

// Authentication hierarchy (most to least secure):
// 1. OAuth 2.0 (Recommended)
// 2. API Keys with proper rotation
// 3. Basic Authentication (Avoid if possible)

Setting up OAuth enforcement:

  1. Configure an OAuth profile for your API
  2. Set up REST API access policies to enforce OAuth
  3. Use com.glide.rest.policy property for security enforcement

Service Account Best Practices:

Additional Security Measures

// API Key validation example
var apiKey = request.headers['X-API-Key'];
if (apiKey !== 'expected_key') {
	response.setStatus(401);
	response.setBody({ error: 'Unauthorized access' });
	return;
}

Security checklist:


Authorization with ACLs

Remove Default ACL

The default ACL Scripted REST External Default contains snc_internal role which exposes your API to all users in your organization. Always create custom ACLs.

Use GlideRecordSecure

// ❌ WRONG - bypasses table ACLs
var gr = new GlideRecord('incident');
 
// ✅ CORRECT - enforces table-level ACLs
var gr = new GlideRecordSecure('incident');
gr.addQuery('active', true);
gr.query();

Creating Custom REST_Endpoint ACLs

  1. Remove the default ACL from your scripted API resources
  2. Create a new ACL of type REST_Endpoint
  3. Add it to your resource with appropriate role conditions
// Role check in script
if (!gs.hasRole('rest_api_user')) {
	response.setStatus(403);
	response.setBody({ error: 'Access denied' });
	return;
}

REST API Access Policies

Use REST API access policies for:


API Design Principles

Follow REST Conventions

HTTP MethodPurposeIdempotent
GETRetrieve data (query only)Yes
POSTCreate new recordsNo
PUTReplace entire recordYes
PATCHPartial updateYes
DELETERemove recordsYes

Critical: GET should NEVER modify data.

Naming Conventions

✅ Good: /api/x_company/v1/users
✅ Good: /api/x_company/v1/incidents/{sys_id}
❌ Bad:  /api/x_company/v1/getUserData
❌ Bad:  /api/x_company/v1/incident_create

Use:

Request/Response Formats

// Always set content type
response.setHeader('Content-Type', 'application/json');
 
// Standard response structure
response.setBody({
	result: {
		// Your data here
	},
	meta: {
		count: 10,
		offset: 0,
		limit: 20
	}
});

Idempotency

API operations should be idempotent—multiple identical requests should produce the same result as a single request. This is critical for reliability in distributed systems where network failures can cause retries.

Reference: Microsoft REST API Guidelines - Idempotency (opens in a new tab), Stripe API Idempotency (opens in a new tab)

Why Idempotency Matters

When a client sends a request and doesn't receive a response (network timeout, server crash), it will retry. Without idempotency, this can create duplicate records or apply changes multiple times.

Implementation Approaches

1. Natural Key Deduplication

(function process(request, response) {
	var data = request.body.data;
 
	// Use a natural key to prevent duplicates
	var gr = new GlideRecordSecure('incident');
	gr.addQuery('correlation_id', data.correlation_id);
	gr.query();
 
	if (gr.next()) {
		// Already exists - return existing record (idempotent)
		response.setStatus(200);
		response.setBody({
			result: {
				sys_id: gr.sys_id.toString(),
				number: gr.number.toString(),
				message: 'Record already exists'
			}
		});
		return;
	}
 
	// Create new record
	gr.initialize();
	gr.correlation_id = data.correlation_id;
	gr.short_description = data.short_description;
	var sys_id = gr.insert();
 
	response.setStatus(201);
	response.setBody({
		result: {
			sys_id: sys_id,
			number: gr.number.toString()
		}
	});
})(request, response);

2. Idempotency Key Header

(function process(request, response) {
	var idempotencyKey = request.getHeader('Idempotency-Key');
 
	if (idempotencyKey) {
		// Check if we've seen this key before
		var cache = new GlideRecord('x_company_api_cache');
		cache.addQuery('idempotency_key', idempotencyKey);
		cache.query();
 
		if (cache.next()) {
			// Return cached response
			response.setStatus(parseInt(cache.status_code));
			response.setBody(JSON.parse(cache.response_body));
			return;
		}
	}
 
	// Process the request
	var result = processRequest(request.body.data);
 
	// Cache the response for future duplicate requests
	if (idempotencyKey) {
		var cacheRecord = new GlideRecord('x_company_api_cache');
		cacheRecord.initialize();
		cacheRecord.idempotency_key = idempotencyKey;
		cacheRecord.status_code = result.success ? 201 : 400;
		cacheRecord.response_body = JSON.stringify(result);
		cacheRecord.insert();
	}
 
	response.setStatus(result.success ? 201 : 400);
	response.setBody(result);
})(request, response);

Idempotency by HTTP Method

MethodIdempotent?Notes
GETYesRead-only, never modifies data
PUTYesReplaces entire resource
DELETEYesDeleting twice = same result
PATCHYes*Should be, but depends on operation
POSTNoUse idempotency keys for create

Versioning

Why Version?

Versioning allows you to:

Implementation

URL Pattern: /api/{namespace}/{version}/{api_id}/{relative_path}
Example:     /api/x_company/v1/incidents
             /api/x_company/v2/incidents

Best Practices:

Versioning Strategies

StrategyExampleProsCons
URL Path/api/v1/accountsClear, cacheableURL changes
Query Param/api/accounts?version=1Easy to implementEasy to miss
HeaderAccept: application/vnd.api.v1+jsonClean URLsLess discoverable

Reference: Microsoft REST API Guidelines - Versioning (opens in a new tab)

Managing Breaking Changes

// v1 remains unchanged
// /api/x_company/v1/user/details
 
// v2 introduces modifications
// /api/x_company/v2/user/details
 
// Always provide migration paths in documentation

Error Handling

Return structured, consistent error responses with actionable information.

Reference: RFC 7807 - Problem Details for HTTP APIs (opens in a new tab), RFC 9110 - HTTP Semantics (opens in a new tab)

Use Standard HTTP Status Codes

OperationSuccess CodeDescription
Create201 CreatedResource successfully created
Update200 OKResource successfully updated
No Change204 No ContentRequest processed, no modification needed
Bad Request400Invalid input data
Unauthorized401Missing or invalid authentication
Forbidden403Insufficient permissions
Not Found404Referenced resource doesn't exist
Conflict409Business rule violation or duplicate
Server Error500Unexpected server-side failure
// Success codes
response.setStatus(200); // OK - successful GET/PUT/PATCH
response.setStatus(201); // Created - successful POST
response.setStatus(204); // No Content - successful DELETE
 
// Client error codes
response.setStatus(400); // Bad Request - invalid input
response.setStatus(401); // Unauthorized - missing/invalid auth
response.setStatus(403); // Forbidden - insufficient permissions
response.setStatus(404); // Not Found - resource doesn't exist
response.setStatus(409); // Conflict - business rule violation
response.setStatus(413); // Payload Too Large
response.setStatus(429); // Too Many Requests (rate limiting)
 
// Server error codes
response.setStatus(500); // Internal Server Error
response.setStatus(503); // Service Unavailable

Structured Error Responses

Include correlation IDs and timestamps for traceability:

{
	"status": 400,
	"error": {
		"code": "VALIDATION_ERROR",
		"message": "Request validation failed",
		"details": [
			{
				"field": "caller_id",
				"issue": "Caller reference not found"
			}
		],
		"correlation_id": "abc123-def456",
		"timestamp": "2025-01-08T10:30:00Z"
	}
}

Implementation:

(function process(request, response) {
	var correlationId =
		request.getHeader('X-Correlation-ID') || gs.generateGUID();
 
	try {
		var requestBody = request.body.data;
 
		// Validation
		if (!requestBody.number || !requestBody.correlation_id) {
			response.setStatus(400);
			response.setBody({
				error: {
					code: 'MISSING_REQUIRED_FIELDS',
					message:
						'Missing required parameters: number, correlation_id',
					details: [],
					correlation_id: correlationId,
					timestamp: new GlideDateTime().getValue()
				}
			});
			return;
		}
 
		// Process request
		var gr = new GlideRecordSecure('incident');
		gr.addQuery('number', requestBody.number);
		gr.query();
 
		if (!gr.next()) {
			response.setStatus(404);
			response.setBody({
				error: {
					code: 'RECORD_NOT_FOUND',
					message:
						'No incident found with number: ' + requestBody.number,
					correlation_id: correlationId,
					timestamp: new GlideDateTime().getValue()
				}
			});
			return;
		}
 
		// Success
		response.setStatus(200);
		response.setBody({
			result: {
				sys_id: gr.sys_id.toString(),
				number: gr.number.toString()
			}
		});
	} catch (ex) {
		gs.error('[' + correlationId + '] API Error: ' + ex.message);
		response.setStatus(500);
		response.setBody({
			error: {
				code: 'INTERNAL_ERROR',
				message: 'An unexpected error occurred',
				correlation_id: correlationId,
				timestamp: new GlideDateTime().getValue()
			}
		});
	}
})(request, response);

Transaction Management

Ensure data consistency through proper transaction handling, especially for batch operations.

Reference: Google API Design Guide - Errors (opens in a new tab)

Batch Operation Strategies

For APIs that process multiple records, choose between:

  1. All-or-nothing - Entire batch fails if any record fails
  2. Partial success - Process what you can, report per-record status

All-or-Nothing Pattern

Validate all records before processing any:

(function process(request, response) {
	var records = request.body.data.records;
	var validationErrors = [];
 
	// Phase 1: Validate ALL records first
	for (var i = 0; i < records.length; i++) {
		var record = records[i];
		var errors = validateRecord(record, i);
		if (errors.length > 0) {
			validationErrors = validationErrors.concat(errors);
		}
	}
 
	// If ANY validation fails, reject entire batch
	if (validationErrors.length > 0) {
		response.setStatus(400);
		response.setBody({
			error: {
				code: 'BATCH_VALIDATION_FAILED',
				message: 'Batch validation failed. No records were processed.',
				details: validationErrors
			}
		});
		return;
	}
 
	// Phase 2: Process all records (validation passed)
	var results = [];
	for (var j = 0; j < records.length; j++) {
		var result = processRecord(records[j]);
		results.push(result);
	}
 
	response.setStatus(201);
	response.setBody({
		result: {
			processed: results.length,
			records: results
		}
	});
})(request, response);
 
function validateRecord(record, index) {
	var errors = [];
	if (!record.short_description) {
		errors.push({
			index: index,
			field: 'short_description',
			issue: 'Required field missing'
		});
	}
	// Add more validations...
	return errors;
}

Partial Success Pattern

Process what you can, report detailed status:

(function process(request, response) {
	var records = request.body.data.records;
	var results = {
		succeeded: [],
		failed: []
	};
 
	for (var i = 0; i < records.length; i++) {
		try {
			var validation = validateRecord(records[i], i);
			if (validation.errors.length > 0) {
				results.failed.push({
					index: i,
					errors: validation.errors
				});
				continue;
			}
 
			var created = processRecord(records[i]);
			results.succeeded.push({
				index: i,
				sys_id: created.sys_id,
				number: created.number
			});
		} catch (ex) {
			results.failed.push({
				index: i,
				errors: [{ issue: ex.message }]
			});
		}
	}
 
	// Use 207 Multi-Status for partial success
	var statusCode =
		results.failed.length === 0
			? 201
			: results.succeeded.length === 0
			? 400
			: 207;
 
	response.setStatus(statusCode);
	response.setBody({
		result: {
			total: records.length,
			succeeded: results.succeeded.length,
			failed: results.failed.length,
			details: results
		}
	});
})(request, response);

When to Use Each Pattern

PatternUse When
All-or-nothingRecords are interdependent
All-or-nothingPartial state would cause data integrity issues
Partial successRecords are independent
Partial successClient can handle/retry individual failures

Performance Optimization

Minimize Database Queries

// ❌ WRONG - Multiple queries in a loop
for (var i = 0; i < ids.length; i++) {
	var gr = new GlideRecord('incident');
	gr.get(ids[i]);
	// process
}
 
// ✅ CORRECT - Single query with IN clause
var gr = new GlideRecord('incident');
gr.addQuery('sys_id', 'IN', ids.join(','));
gr.query();
while (gr.next()) {
	// process
}

Implement Pagination

(function process(request, response) {
	var limit = parseInt(request.queryParams['limit']) || 20;
	var offset = parseInt(request.queryParams['offset']) || 0;
 
	// Cap maximum limit
	if (limit > 100) limit = 100;
 
	var gr = new GlideRecordSecure('incident');
	gr.addQuery('active', true);
	gr.orderBy('sys_created_on');
	gr.chooseWindow(offset, offset + limit);
	gr.query();
 
	var results = [];
	while (gr.next()) {
		results.push({
			sys_id: gr.sys_id.toString(),
			number: gr.number.toString()
		});
	}
 
	// Get total count for pagination metadata
	var countGr = new GlideAggregate('incident');
	countGr.addQuery('active', true);
	countGr.addAggregate('COUNT');
	countGr.query();
	var total = 0;
	if (countGr.next()) {
		total = parseInt(countGr.getAggregate('COUNT'));
	}
 
	response.setBody({
		result: results,
		meta: {
			total: total,
			limit: limit,
			offset: offset,
			hasMore: offset + limit < total
		}
	});
})(request, response);

Additional Performance Tips


Input Validation

Validate all input data before processing. Fail fast with clear error messages.

Reference: OWASP Input Validation Cheat Sheet (opens in a new tab)

Validation checklist:

Validate Required Parameters

(function process(request, response) {
	var body = request.body.data;
	var errors = [];
 
	// Required field validation
	if (!body.caller_id) {
		errors.push({ field: 'caller_id', message: 'Caller ID is required' });
	}
 
	if (!body.short_description) {
		errors.push({
			field: 'short_description',
			message: 'Short description is required'
		});
	}
 
	// Type validation
	if (body.priority && isNaN(parseInt(body.priority))) {
		errors.push({
			field: 'priority',
			message: 'Priority must be a number'
		});
	}
 
	// Range validation
	if (body.priority && (body.priority < 1 || body.priority > 5)) {
		errors.push({
			field: 'priority',
			message: 'Priority must be between 1 and 5'
		});
	}
 
	// Payload size validation
	if (JSON.stringify(body).length > 10000) {
		response.setStatus(413);
		response.setBody({ error: 'Request payload too large' });
		return;
	}
 
	if (errors.length > 0) {
		response.setStatus(400);
		response.setBody({
			error: {
				code: 'VALIDATION_ERROR',
				message: 'Validation failed',
				details: errors
			}
		});
		return;
	}
 
	// Continue processing...
})(request, response);

Prevent Injection Attacks

// Use parameterized queries with GlideRecord (built-in protection)
var gr = new GlideRecordSecure('incident');
gr.addQuery('number', userInput); // Safe - GlideRecord handles escaping
gr.query();
 
// Never construct encoded queries from raw user input
// ❌ WRONG
gr.addEncodedQuery('short_description=' + userInput);
 
// ✅ CORRECT - Use addQuery methods
gr.addQuery('short_description', 'CONTAINS', userInput);

Logging & Debugging

Implement structured logging with correlation IDs for end-to-end traceability.

Reference: The Twelve-Factor App - Logs (opens in a new tab)

Logging Levels

LevelUse Case
gs.debug()Detailed diagnostic information
gs.info()General operational events
gs.warn()Recoverable issues, deprecations
gs.error()Failures requiring attention

Implement Comprehensive Logging

(function process(request, response) {
	var startTime = new Date().getTime();
	var requestId = gs.generateGUID();
 
	// Log request
	gs.info(
		'[' +
			requestId +
			'] API Request: ' +
			request.uri +
			' | Method: ' +
			request.httpMethod +
			' | User: ' +
			gs.getUserName()
	);
 
	try {
		// Process request
		var result = processRequest(request);
 
		var duration = new Date().getTime() - startTime;
		gs.info(
			'[' + requestId + '] API Success | Duration: ' + duration + 'ms'
		);
 
		response.setStatus(200);
		response.setBody(result);
	} catch (ex) {
		var duration = new Date().getTime() - startTime;
		gs.error(
			'[' +
				requestId +
				'] API Error: ' +
				ex.message +
				' | Duration: ' +
				duration +
				'ms'
		);
 
		response.setStatus(500);
		response.setBody({ error: 'Internal server error' });
	}
})(request, response);

Debugging Techniques

  1. Enable REST debugging property:

    Property: glide.rest.debug = true
  2. Use the Script Debugger:

    • Set breakpoints in your scripted REST API code
    • Use REST API Explorer to trigger the endpoint
    • Step through code in the debugger
  3. Session debugging:

    • Use session debug options for ACL, Business Rule issues
    • Add X-WantDebugMessages header for debug output
  4. Log File Tailer:

    • View node log in real-time during testing
// Debug statements in code
gs.debug('Processing request for incident: ' + incidentNumber);
gs.info('Record created successfully: ' + gr.sys_id);
gs.warn('Deprecated parameter used: ' + paramName);
gs.error('Failed to create record: ' + errorMessage);

Documentation

What to Document

Documentation Tools

Example Documentation Format

## Create Incident
 
Creates a new incident record.
 
**Endpoint:** `POST /api/x_company/v1/incidents`
 
**Authentication:** OAuth 2.0 required
 
**Request Body:**
| Field | Type | Required | Description |
| ----------------- | ------- | -------- | -------------------- |
| caller_id | string | Yes | User sys_id or email |
| short_description | string | Yes | Brief description |
| priority | integer | No | 1-5 (default: 4) |
 
**Example Request:**
{json}
{
"caller_id": "user@example.com",
"short_description": "Unable to access email",
"priority": 3
}
{/json}
 
**Response Codes:**
 
-   201: Incident created successfully
-   400: Invalid request parameters
-   401: Authentication required
-   403: Insufficient permissions

Testing

Testing Tools

  1. REST API Explorer - Built-in ServiceNow testing
  2. Postman - External API testing
  3. Automated Test Framework (ATF) - Create Inbound REST test steps

Test Categories

// Unit tests for individual functions
// Integration tests for end-to-end flows
// Security tests for authentication/authorization
// Performance tests for load/stress testing
// Negative tests for error handling

ATF Integration

// Create an Inbound REST Test Step in ATF
// Validate:
// - Authentication success/failure
// - Correct response codes
// - Response body structure
// - Data integrity

Pre-Production Checklist


Code Architecture

Use Script Includes for Reusability

// Script Include: IncidentAPI
var IncidentAPI = Class.create();
IncidentAPI.prototype = {
	initialize: function () {},
 
	createIncident: function (data) {
		var gr = new GlideRecordSecure('incident');
		gr.initialize();
		gr.caller_id = data.caller_id;
		gr.short_description = data.short_description;
		gr.priority = data.priority || 4;
 
		var sys_id = gr.insert();
		if (sys_id) {
			return {
				success: true,
				sys_id: sys_id,
				number: gr.number.toString()
			};
		}
		return { success: false, error: 'Failed to create incident' };
	},
 
	getIncident: function (sys_id) {
		var gr = new GlideRecordSecure('incident');
		if (gr.get(sys_id)) {
			return {
				success: true,
				data: {
					sys_id: gr.sys_id.toString(),
					number: gr.number.toString(),
					short_description: gr.short_description.toString(),
					state: gr.state.getDisplayValue()
				}
			};
		}
		return { success: false, error: 'Incident not found' };
	},
 
	type: 'IncidentAPI'
};
 
// Scripted REST API Resource
(function process(request, response) {
	var api = new IncidentAPI();
	var result = api.createIncident(request.body.data);
 
	if (result.success) {
		response.setStatus(201);
		response.setBody({ result: result });
	} else {
		response.setStatus(400);
		response.setBody({ error: result.error });
	}
})(request, response);

Multiple Resources Under One API

Group related resources under a single Scripted REST API for easier management:

API: x_company/incident_management/v1
├── Resource: /incidents (GET, POST)
├── Resource: /incidents/{sys_id} (GET, PUT, DELETE)
├── Resource: /incidents/{sys_id}/comments (GET, POST)
└── Resource: /incidents/{sys_id}/attachments (GET, POST)

Complete Endpoint Template

Copy this boilerplate as a starting point for new endpoints. It incorporates validation, error handling, logging, Script Include usage, and proper response structure.

Script Include (Business Logic)

/**
 * Script Include: IncidentAPIService
 * API Name: x_company
 * Accessible from: All application scopes
 */
var IncidentAPIService = Class.create();
IncidentAPIService.prototype = {
	initialize: function () {
		this.LOG_SOURCE = 'IncidentAPIService';
	},
 
	/**
	 * Validates incident creation payload
	 * @param {Object} data - Request payload
	 * @returns {Object} { valid: boolean, errors: Array }
	 */
	validateCreatePayload: function (data) {
		var errors = [];
 
		// Required fields
		if (!data.caller_id) {
			errors.push({
				field: 'caller_id',
				issue: 'Required field missing'
			});
		}
 
		if (!data.short_description) {
			errors.push({
				field: 'short_description',
				issue: 'Required field missing'
			});
		}
 
		// Type validation
		if (data.priority && isNaN(parseInt(data.priority))) {
			errors.push({
				field: 'priority',
				issue: 'Must be a number'
			});
		}
 
		// Range validation
		if (data.priority) {
			var p = parseInt(data.priority);
			if (p < 1 || p > 5) {
				errors.push({
					field: 'priority',
					issue: 'Must be between 1 and 5'
				});
			}
		}
 
		// Reference validation
		if (data.caller_id && !this._userExists(data.caller_id)) {
			errors.push({
				field: 'caller_id',
				issue: 'User not found: ' + data.caller_id
			});
		}
 
		return {
			valid: errors.length === 0,
			errors: errors
		};
	},
 
	/**
	 * Creates a new incident
	 * @param {Object} data - Validated request payload
	 * @returns {Object} { success: boolean, sys_id?, number?, error? }
	 */
	createIncident: function (data) {
		var gr = new GlideRecordSecure('incident');
		gr.initialize();
		gr.caller_id = data.caller_id;
		gr.short_description = data.short_description;
		gr.description = data.description || '';
		gr.priority = data.priority || 4;
		gr.correlation_id = data.correlation_id || '';
 
		var sys_id = gr.insert();
 
		if (sys_id) {
			return {
				success: true,
				sys_id: sys_id,
				number: gr.number.toString()
			};
		}
 
		return {
			success: false,
			error: 'Failed to create incident - check ACLs and mandatory fields'
		};
	},
 
	/**
	 * Retrieves an incident by sys_id
	 * @param {string} sysId - Incident sys_id
	 * @returns {Object} { success: boolean, data?, error? }
	 */
	getIncident: function (sysId) {
		if (!this._isValidSysId(sysId)) {
			return { success: false, error: 'Invalid sys_id format' };
		}
 
		var gr = new GlideRecordSecure('incident');
		if (gr.get(sysId)) {
			return {
				success: true,
				data: {
					sys_id: gr.sys_id.toString(),
					number: gr.number.toString(),
					short_description: gr.short_description.toString(),
					state: gr.state.getDisplayValue(),
					priority: gr.priority.getDisplayValue(),
					caller_id: gr.caller_id.getDisplayValue(),
					created_on: gr.sys_created_on.toString(),
					updated_on: gr.sys_updated_on.toString()
				}
			};
		}
 
		return { success: false, error: 'Incident not found' };
	},
 
	// Private helper methods
	_userExists: function (userId) {
		var gr = new GlideRecord('sys_user');
		return gr.get(userId) || gr.get('email', userId);
	},
 
	_isValidSysId: function (sysId) {
		return sysId && /^[a-f0-9]{32}$/i.test(sysId);
	},
 
	type: 'IncidentAPIService'
};

REST Resource Script (POST - Create)

(function process(request, response) {
	// ========================================
	// 1. SETUP: Correlation ID & Logging
	// ========================================
	var correlationId =
		request.getHeader('X-Correlation-ID') || gs.generateGUID();
	var startTime = new Date().getTime();
	var LOG_SOURCE = 'IncidentAPI.POST';
 
	gs.info(
		'[' +
			correlationId +
			'] ' +
			LOG_SOURCE +
			' - Request received | User: ' +
			gs.getUserName()
	);
 
	// Helper function for consistent error responses
	function sendError(status, code, message, details) {
		var duration = new Date().getTime() - startTime;
		gs.warn(
			'[' +
				correlationId +
				'] ' +
				LOG_SOURCE +
				' - ' +
				code +
				': ' +
				message +
				' | Duration: ' +
				duration +
				'ms'
		);
 
		response.setStatus(status);
		response.setBody({
			error: {
				code: code,
				message: message,
				details: details || [],
				correlation_id: correlationId,
				timestamp: new GlideDateTime().getValue()
			}
		});
	}
 
	// Helper function for success responses
	function sendSuccess(status, result) {
		var duration = new Date().getTime() - startTime;
		gs.info(
			'[' +
				correlationId +
				'] ' +
				LOG_SOURCE +
				' - Success | Duration: ' +
				duration +
				'ms'
		);
 
		response.setStatus(status);
		response.setBody({
			result: result,
			meta: {
				correlation_id: correlationId,
				timestamp: new GlideDateTime().getValue()
			}
		});
	}
 
	try {
		// ========================================
		// 2. AUTHORIZATION CHECK (optional extra check)
		// ========================================
		if (!gs.hasRole('x_company.api_user')) {
			sendError(403, 'FORBIDDEN', 'Insufficient permissions');
			return;
		}
 
		// ========================================
		// 3. PARSE & VALIDATE REQUEST BODY
		// ========================================
		var requestBody = request.body.data;
 
		if (!requestBody || Object.keys(requestBody).length === 0) {
			sendError(400, 'EMPTY_BODY', 'Request body is required');
			return;
		}
 
		// Use Script Include for validation
		var api = new IncidentAPIService();
		var validation = api.validateCreatePayload(requestBody);
 
		if (!validation.valid) {
			sendError(
				400,
				'VALIDATION_ERROR',
				'Request validation failed',
				validation.errors
			);
			return;
		}
 
		// ========================================
		// 4. IDEMPOTENCY CHECK (optional)
		// ========================================
		if (requestBody.correlation_id) {
			var existing = new GlideRecord('incident');
			existing.addQuery('correlation_id', requestBody.correlation_id);
			existing.setLimit(1);
			existing.query();
 
			if (existing.next()) {
				// Return existing record (idempotent behavior)
				gs.info(
					'[' +
						correlationId +
						'] ' +
						LOG_SOURCE +
						' - Duplicate request, returning existing record'
				);
				sendSuccess(200, {
					sys_id: existing.sys_id.toString(),
					number: existing.number.toString(),
					message: 'Record already exists (idempotent response)'
				});
				return;
			}
		}
 
		// ========================================
		// 5. PROCESS REQUEST (via Script Include)
		// ========================================
		var result = api.createIncident(requestBody);
 
		if (!result.success) {
			sendError(500, 'CREATE_FAILED', result.error);
			return;
		}
 
		// ========================================
		// 6. RETURN SUCCESS RESPONSE
		// ========================================
		sendSuccess(201, {
			sys_id: result.sys_id,
			number: result.number
		});
	} catch (ex) {
		// ========================================
		// 7. UNEXPECTED ERROR HANDLING
		// ========================================
		gs.error(
			'[' +
				correlationId +
				'] ' +
				LOG_SOURCE +
				' - Unexpected error: ' +
				ex.message
		);
		sendError(500, 'INTERNAL_ERROR', 'An unexpected error occurred');
	}
})(request, response);

REST Resource Script (GET - Retrieve)

(function process(request, response) {
	// ========================================
	// 1. SETUP
	// ========================================
	var correlationId =
		request.getHeader('X-Correlation-ID') || gs.generateGUID();
	var startTime = new Date().getTime();
	var LOG_SOURCE = 'IncidentAPI.GET';
 
	gs.info('[' + correlationId + '] ' + LOG_SOURCE + ' - Request received');
 
	function sendError(status, code, message) {
		response.setStatus(status);
		response.setBody({
			error: {
				code: code,
				message: message,
				correlation_id: correlationId,
				timestamp: new GlideDateTime().getValue()
			}
		});
	}
 
	function sendSuccess(result) {
		var duration = new Date().getTime() - startTime;
		gs.info(
			'[' +
				correlationId +
				'] ' +
				LOG_SOURCE +
				' - Success | Duration: ' +
				duration +
				'ms'
		);
		response.setStatus(200);
		response.setBody({
			result: result,
			meta: {
				correlation_id: correlationId,
				timestamp: new GlideDateTime().getValue()
			}
		});
	}
 
	try {
		// ========================================
		// 2. GET PATH PARAMETER
		// ========================================
		var sysId = request.pathParams.sys_id;
 
		if (!sysId) {
			sendError(400, 'MISSING_PARAMETER', 'sys_id path parameter required');
			return;
		}
 
		// ========================================
		// 3. RETRIEVE RECORD (via Script Include)
		// ========================================
		var api = new IncidentAPIService();
		var result = api.getIncident(sysId);
 
		if (!result.success) {
			sendError(404, 'NOT_FOUND', result.error);
			return;
		}
 
		// ========================================
		// 4. RETURN SUCCESS RESPONSE
		// ========================================
		sendSuccess(result.data);
	} catch (ex) {
		gs.error(
			'[' +
				correlationId +
				'] ' +
				LOG_SOURCE +
				' - Unexpected error: ' +
				ex.message
		);
		sendError(500, 'INTERNAL_ERROR', 'An unexpected error occurred');
	}
})(request, response);

Template Checklist

When using this template, ensure you've:


Common Pitfalls to Avoid

Security Pitfalls

❌ Don't✅ Do
Use default ACLCreate custom REST_Endpoint ACLs
Use GlideRecordUse GlideRecordSecure
Pass sensitive data in URLsUse request body or headers
Skip authentication on any endpointRequire auth on all endpoints
Use Basic AuthUse OAuth 2.0

Design Pitfalls

❌ Don't✅ Do
Use GET for data modificationUse POST/PUT/PATCH/DELETE appropriately
Include request body in GETUse query parameters for filtering
Return all fields alwaysReturn only necessary fields
Skip versioningVersion from day one
Hardcode valuesUse system properties/config

Performance Pitfalls

❌ Don't✅ Do
Query in loopsUse batch queries with IN clause
Return unlimited resultsImplement pagination
Skip cachingCache when appropriate
Process synchronously alwaysUse async for long operations

Code Pitfalls

❌ Don't✅ Do
Write all logic in resource scriptUse Script Includes
Skip error handlingImplement comprehensive try-catch
Skip loggingLog requests, responses, errors
Skip input validationValidate all inputs

Quick Reference Checklist

Before Deployment


References

ServiceNow Documentation

Industry Standards & Guidelines

Security

Architecture & Practices

Certification Prep

Scripted REST API design is a commonly tested topic in ServiceNow certification exams, including CAD and CIS. SNReady (opens in a new tab) provides practice questions with detailed explanations to help you prepare.