Ch. 17 · ServiceNow

ServiceNow REST API Design

When the Table API is enough, when to build a Scripted REST API with its own contract, and how versioning lets it change safely.

~6 min readadvancedupdated Oct 19, 2026

ServiceNow gives you a REST API for every table on day one. GET /api/now/table/incident?sysparm_query=active=true returns incidents, POST creates them, and access is governed by the same ACLs as the UI. For an internal script or a quick report, that’s often all you need. The trouble starts when an external partner builds against it, because now your table design is their contract.

The interview version of this topic is a design question: “A vendor needs to read and update tickets in our instance. How would you expose that?” Strong answers compare the Table API with a Scripted REST API, explain what versioning buys you, and say how security works in each.

What the Table API commits you to

The Table API returns records using internal names. A caller sees state: "6", caller_id as a sys_id (or a link object, depending on sysparm_exclude_reference_link), and every custom field with its u_ or scope prefix. You can trim the output with sysparm_fields and ask for labels with sysparm_display_value=true, but those are choices the caller makes, not guarantees you give.

That has three consequences. First, any schema change is a potential breaking change: rename u_cust_tier, change a choice value, or move a field to a child table and some integration fails at 3 a.m. Second, callers learn your internals: which tables exist, how states are numbered, which custom fields you’ve added. Third, business rules decide what’s valid. If the vendor sends a state transition you don’t allow, they find out from a generic error rather than a clear response from an API designed for them.

None of this makes the Table API wrong. It’s the right tool for trusted, internal callers who accept coupling to the schema, such as your own automation or a reporting extract owned by the same team.

A Scripted REST API is a contract you own

A Scripted REST API lets you define the URL, the methods, the request and response shape, and the status codes. Its URI includes a namespace and an API ID, for example /api/x_acme_vendor/tickets, and each resource is a script that receives request and response objects.

The design move is to separate the internal record from the external shape with a mapping function. Here’s that mapping modelled in Node, using fields an incident would have:

const STATUS = { 1: 'new', 2: 'in_progress', 3: 'on_hold', 6: 'resolved', 7: 'closed', 8: 'canceled' };

// v1: the contract partners signed up for. Field names are ours, not the table's.
const v1 = (r) => ({
  ticket: r.number,
  status: STATUS[r.state] ?? 'unknown',
  summary: r.short_description,
  updatedAt: r.sys_updated_on.replace(' ', 'T') + 'Z',
});
JavaScript

Running it against a sample row and checking the result with assert.deepEqual, then simulating an admin renaming u_cust_tier to u_customer_tier:

v1 {"ticket":"INC0010042","status":"resolved","summary":"VPN drops every 10 minutes","updatedAt":"2026-10-19T08:14:02Z"}
v2 {"ticket":"INC0010042","status":{"code":"resolved","open":false},"summary":"VPN drops every 10 minutes","tier":"gold","updatedAt":"2026-10-19T08:14:02Z"}
v2 after rename, mapping not updated: null
Text

The partner sees status: "resolved", not "6". The timestamp is ISO 8601 in UTC, which is how sys_updated_on is stored, rather than the instance’s display format. The rename didn’t affect v1 at all. It did make v2’s tier silently become null, which is the point of keeping contract tests: the mapping is now the one place that has to change, and a test catches it before a caller does.

On the instance, the resource script wraps that mapping. It’s illustrative; no instance was available to run it.

// Scripted REST resource: GET /api/x_acme_vendor/tickets/v1/{number}
(function process(request, response) {
  var number = request.pathParams.number;
  var gr = new GlideRecordSecure('incident');   // applies the caller's ACLs
  gr.addQuery('number', number);
  gr.addQuery('company', new VendorAccess().companyFor(gs.getUserID()));
  gr.setLimit(1);
  gr.query();
  if (!gr.next()) {
    response.setStatus(404);
    response.setBody({ error: { code: 'not_found', message: 'No ticket ' + number } });
    return;
  }
  response.setStatus(200);
  response.setBody(new VendorTicketMapper().v1(gr));
})(request, response);
JavaScript

Two choices in that script matter in an interview. GlideRecordSecure checks ACLs for the authenticated user, whereas plain GlideRecord in a scripted resource reads anything, so a careless resource bypasses all your table security. And the query is scoped to the vendor’s company, so one vendor can’t read another’s tickets by guessing numbers. Returning 404 rather than 403 for someone else’s ticket avoids confirming that it exists.

Turning on versioning

Versioning is a switch on the Scripted REST API record. ServiceNow’s documentation describes it as a related link, “Enable versioning”, that adds a Versioning list to the API form, and by default it makes v1 the default version. With versioning on, each resource belongs to a version, and the version appears in the URI: /api/x_acme_vendor/tickets/v1/INC0010042. If a default version is set, the URI without a version (/api/x_acme_vendor/tickets/INC0010042) is routed to it. You can also enable versioning with no default, which forces callers to name a version.

Enable it before the first external caller goes live, and tell callers to always use the versioned URI. If they integrate against the unversioned path and you later change the default to v2, you’ve broken them just as surely as editing v1 in place.

What counts as breaking? Removing or renaming a field, changing a type (status from a string to an object, as in the v2 above), changing status codes or error shapes, or making an optional input required. Adding a new optional response field usually isn’t breaking, as long as callers have been told to ignore fields they don’t recognise. When in doubt, add it to v1 only if your contract says unknown fields are allowed; otherwise put it in v2.

Note

Keep v1 resources thin by having both versions call the same script include, with a separate mapping per version. Bug fixes in the shared query then reach every version, and the only code that differs is the shape.

Errors, writes and other details callers notice

A designed API should return errors in one consistent shape, such as { "error": { "code": "...", "message": "..." } }, with status codes that mean something: 400 for invalid input, 404 for not found, 409 for a state transition that isn’t allowed, 422 if you prefer it for validation failures. Validate inputs in the resource and return field-level messages, instead of letting a business rule abort the insert with a generic message.

For writes, decide whether the API accepts the caller’s own identifiers (a vendor reference number) and enforce uniqueness so a retried POST doesn’t create duplicates. A correlation field on the record, checked before insert, gives you idempotency without anything clever. Authenticate integrations with a dedicated integration user per partner, with only the roles that API needs, so you can revoke one partner without touching others and audit who changed what.

Practice question: the vendor and the renamed field

A vendor integrated directly with the Table API on incident last year. Your team now needs to replace a custom choice field u_vendor_status with a new field on a different table, and the vendor says they can’t change their code for six months. What do you do?

Answer

Because the vendor is coupled to the table schema, the schema change is a breaking change for them. I wouldn’t hold the internal redesign hostage, and I wouldn’t keep both fields synchronised forever. Instead I’d build a Scripted REST API with versioning enabled and a v1 that reproduces exactly what the vendor uses today: the same fields they read, with the old u_vendor_status values computed from the new data through a mapping. That keeps them working while the schema changes underneath. During the six months, I’d publish a v2 that reflects the new model and give them a migration window, with monitoring on which version each integration user calls so we know when v1 can be retired. I’d also move the vendor to a dedicated integration user with only the roles the new API needs, and use GlideRecordSecure in the resources so their access is still limited by ACLs. The lesson for the next partner is to start with the Scripted API, not the Table API.

More in ServiceNow

read ✓ServiceNow · hard

ServiceNow ACL Scripting and Access

When an ACL really needs a script, how to write one that is cheap and null-safe on every row, and how to prove it works by testing as the user.

~7 min readread →
read ✓ServiceNow · hard

ServiceNow Caching and Invalidation

What a ServiceNow instance caches on each node, why a system property change triggers a costly flush, and where changing settings belong.

~7 min readread →
read ✓ServiceNow · mid

ServiceNow Dictionary and Field Audit

How dictionary entries and per-table overrides define a field, how auditing writes to sys_audit, and which fields you should keep out of history.

~6 min readread →
esc