DevOps Platform

Automate ServiceNow Change Requests from a CI/CD Pipeline via the Change API

A payments-platform team ships forty times a week, but every production deploy still stalls at the same wall: a human opens a ServiceNow change request by hand, pastes a commit URL and a rollback plan into the form, waits for a CAB approver to click Approve, runs the deploy, then comes back hours later to close the record — and half the time forgets, so the change backlog and the actual production state drift apart and the next audit turns into archaeology. The mandate from the CIO is blunt: keep the change-control discipline auditors require, but make it a property of the pipeline, not a chore a tired engineer remembers at 2 a.m. This guide builds exactly that — a deploy workflow that creates a ServiceNow change record from the commit, gates the deploy on the change reaching an approved state, executes, attaches the artefacts and test results as evidence, then auto-closes the record with a truthful outcome — using ServiceNow’s Change Management API (sn_chg_rest), an OAuth-authenticated integration account, and a credential leased from HashiCorp Vault. When you are done, the change request is the deploy, and the two can never disagree.

This is worth building carefully — rather than hacking a curl into a pipeline step — because a change record is a legal and audit artefact, not a status message: it carries the who, the what, the when, the approved-by, the risk assessment, the implementation and backout plans, and the evidence. Get it wrong and you have either control theatre (a change that auto-approves itself, failing a SOC 2 or ITGC audit on separation-of-duties) or a bottleneck (a Normal change gated on a slow CAB queue that blocks every micro-deploy). The craft is in matching which change type fits which deploy risk: the standard-change (pre-approved) fast path for the 90% of low-risk deploys, the Normal (CAB-gated) path for the risky 10%, and the whole thing observable, revocable and honest.

By the end you will stop treating change management as friction bolted onto delivery. You will know the difference between the Change API and the Table API; how the DevOps Change Velocity (formerly DevOps Change Acceleration) app auto-creates and auto-approves changes from pipeline telemetry with zero custom code; how to author a standard change template so a class of deploys is pre-approved; how to build the open → wait-for-approval → deploy → attach → close gate in both GitHub Actions and Azure DevOps; and how to keep the machine account least-privileged so it can progress a change but never approve its own. Every mechanism comes with real API calls, real pipeline YAML, and the exact field it writes.

What problem this solves

In a regulated shop, every production change must be authorised, recorded, and reviewable. ITIL (and the auditors who lean on it) demand that changes are classified by risk, approved by someone other than the person making them, implemented against a documented plan, and closed with an outcome. Done by hand, this is a tax on velocity: the engineer context-switches from the deploy to a ServiceNow form, retypes what the pipeline already knows (commit, PR, build number, test results), waits on a human, then has to remember to come back and close the record with the result. The failure modes are predictable and expensive.

What breaks without automation: drift between the change log and reality (changes left open, or closed as “successful” when the deploy actually rolled back, because closing is a manual afterthought); shadow change (engineers deploy first and back-date a change, or skip it entirely under deadline pressure, so the audit trail is fiction); CAB as a bottleneck (low-risk, reversible deploys queued behind the same weekly CAB meeting as a database migration, so a one-line config change waits three days); and evidence gaps (an auditor asks “prove this deploy was tested and approved” and the change record has a title but no artefacts, no test results, no link back to the pipeline run). Each of these is a finding waiting to happen, and each is solved by making the pipeline the author of the change.

Who hits this: any organisation running ServiceNow ITSM as the system of record while trying to ship continuously — banks, insurers, healthcare, government, and any enterprise with a Change Advisory Board. It bites hardest on platform and SRE teams who own dozens of services and hundreds of deploys a week, where manual change raising is simply not survivable at that cadence. The fix is not “abolish change management” (the auditors win that argument) — it is to automate the honest parts (creation, evidence, closure) and keep the human gate exactly where risk demands it (approval of non-standard changes), so the control stays real and the velocity survives.

To frame the whole field before the deep dive, here is the core decision every team faces — which change type maps to which deploy, and what that costs you in speed and control:

Deploy risk Change type Approval Typical latency When it fits Audit posture
Low, reversible, routine Standard (templated) Pre-approved (no CAB) Seconds Config toggles, well-worn app deploys, patches you do weekly Strongest — the template was approved once by CAB and every instance inherits it
Medium, some blast radius Normal CAB / peer / automated risk-based Minutes to days New service, schema change, infra change Full risk assessment + human sign-off recorded
High, urgent, break-fix Emergency Expedited (e-CAB / on-call manager) Minutes P1 incident fix, security hotfix Post-implementation review required
Continuous, telemetry-driven Normal via DevOps Change Velocity Automated based on pipeline signals Seconds if policy passes High-frequency delivery on trusted pipelines Change auto-created + auto-approved from build/test/security gates

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

This sits at the seam between delivery and governance. It assumes you already understand a CI/CD pipeline end to end — if not, read CI/CD Pipeline Explained first — and it pairs tightly with passwordless pipeline auth (GitHub Actions to Azure with OIDC federated credentials) and pipeline secret hygiene (Managing pipeline secrets with Key Vault and managed identity). On the ServiceNow side it complements ServiceNow ITSM for cloud incident response & event management and ServiceNow-gated cloud provisioning for self-service landing zones, which use the same instance as the system of record for a different workflow.

A quick map of who owns which layer, so you route a problem to the right team fast:

Layer What lives here Who owns it Failure it causes
Pipeline (Actions / ADO) The deploy jobs, the gate logic, the sys_id handoff Platform / SRE Deploy runs without a change; gate not enforced
Auth (OAuth + Vault) Token minting, secret lease, OIDC role binding Platform + Security invalid_client, access_denied, leaked secret
Change API (sn_chg_rest) Create / patch / close / attach the record ServiceNow platform team 400 on wrong endpoint; .value parsing bugs
State model The numeric states (-5…4) and transitions ServiceNow admin Hard-coded states break after a custom model change
Approval workflow CAB, standard-template pre-approval, risk assessment Change manager / CAB Deploy stalls; or worse, auto-approves (SoD break)
Identity (Entra / Okta) Human approver SSO + MFA IAM team Approver can’t sign in; SoD not provable

Core concepts

Six mental models make every later decision obvious.

The change record is an audit artefact, not a status message. A ServiceNow change_request row carries the who (requested_by, assigned_to), the what (short_description, description, the CI it affects), the classification (type, risk, impact), the plans (implementation_plan, backout_plan, test_plan), the sign-off (approval, the approval records), the schedule (start_date, end_date), and the outcome (close_code, close_notes, plus any attachments). When the pipeline authors it, the pipeline must fill the fields an auditor will read — which is why “just POST a title” is not enough.

Three change types, three risk postures. ITIL splits changes into Standard (pre-approved, low-risk, routine, a template that CAB blessed once and every instance inherits), Normal (needs assessment and approval per instance — CAB, a peer, or an automated risk rule), and Emergency (urgent break-fix, expedited approval, retrospective review). Matching the deploy’s risk to the change type is the single most important design choice: put routine deploys on standard changes (fast path, no CAB) and reserve the human gate for changes that genuinely warrant it.

The Change API is a workflow-aware wrapper over the Table API. ServiceNow exposes raw table CRUD at /api/now/table/change_request (the Table API), and a purpose-built Change Management API at /api/sn_chg_rest/change (the Change API). The Change API runs the change process — it respects the state model, fires the approval workflow, understands standard templates (/change/standard/{sys_id}), and validates transitions — where the Table API is a dumb setter that will happily write an illegal state and skip every business rule. Prefer the Change API precisely because it enforces the process you are trying to automate.

The sys_id is the thread that stitches the pipeline together. When you create a change you get back its immutable sys_id (a 32-char GUID) and its human number (e.g. CHG0031245). Every later step — poll for approval, move to Implement, attach evidence, close — targets that sys_id. In a multi-job pipeline you pass the sys_id between jobs as an output, and it is the one identifier that guarantees every step acts on the same change.

Every field is a {value, display_value} pair. The Change API does not return "state": "-1"; it returns "state": {"value": "-1", "display_value": "Implement"}. This is deliberate — value is the stored/database form your code branches on, display_value is the human label. Read .result.state.value in your jq, never .result.state, or you get an object where you expected a string and every conditional silently misfires. This trips up everyone exactly once.

Approval is a human gate you must not let the machine cross. The whole point of change control is separation of duties: the person (or system) making the change is not the person approving it. The pipeline’s integration user may create and progress a change, but the approval must come from a real CAB member authenticating through corporate SSO with MFA — or from a standard template that was itself approved by a human once. If the integration user can approve its own changes, the control is theatre and the audit fails. Design the roles so this is impossible, not merely discouraged.

The vocabulary in one table

Pin down every moving part before the deep sections; the glossary repeats these for lookup:

Term One-line definition Where it lives Why it matters
Change request (change_request) The record representing a planned change ServiceNow table The audit artefact the pipeline authors
Change type Standard / Normal / Emergency type field Chooses the approval path and speed
Standard change template A pre-approved, reusable change definition std_change_producer_version Lets a whole class of deploys skip CAB
Change API (sn_chg_rest) Workflow-aware REST API for changes Scripted REST resource Enforces the process; prefer over Table API
Table API (/api/now/table) Raw CRUD over any table Platform REST Dumb setter; bypasses business rules
State model The -5…4 numeric change states + transitions Change Management config Your state values must match the instance
sys_id Immutable 32-char record GUID Every record The thread stitching pipeline jobs together
approval field not requested/requested/approved/rejected approval field What the gate polls on
DevOps Change Velocity ServiceNow app that auto-creates/approves changes from pipeline telemetry Store app The zero-code alternative to hand-rolled API
CAB Change Advisory Board — the human approvers People + workflow The gate for Normal/high-risk changes
OAuth API endpoint The Application Registry record for token auth System OAuth How the pipeline authenticates as a machine
Attachment API REST API to attach files to a record /api/now/attachment How evidence (test results, artefacts) lands on the change

Change API vs Table API: pick the right door

Two APIs can touch a change record, and choosing wrong is the most common architectural mistake. The Table API (/api/now/table/change_request) is generic CRUD — it will read, insert, and update any field on any table you have rights to, including change_request. The Change API (/api/sn_chg_rest/change and its sub-resources) is a scripted REST API that understands the change process: it drives the state model, triggers approvals, knows about standard templates, computes risk, and refuses illegal transitions. They overlap in what they can write, but they differ profoundly in what they enforce.

Capability Change API (sn_chg_rest) Table API (/api/now/table)
Create a change Yes — POST /change (Normal), POST /change/standard/{tpl} (Standard), POST /change/emergency Yes — POST /table/change_request with type field
Respects the state model / valid transitions Yes — refuses illegal state jumps No — writes whatever you send, even illegal states
Fires the approval workflow on create Yes — Normal changes enter the approval flow No — you’d have to trigger approvals manually
Understands standard templates Yes — dedicated endpoint applies the template + pre-approval No — you’d set fields by hand and lose pre-approval semantics
Computes risk / conflict Yes — risk assessment + conflict detection hooks run Not automatically
Returns {value, display_value} Yes Yes (with sysparm_display_value=all)
Runs business rules / data policies Yes (process-aware) Yes for table-level rules, but not change-process logic
Field-level flexibility (write any obscure field) Slightly narrower (curated fields + ?) Total — any column on the table
Best for Driving the change process from a pipeline Bulk reads, reporting, or writing fields the Change API doesn’t expose

The rule: use the Change API to run the process, and drop to the Table API only for the gaps — for example, reading a template’s sys_id, listing changes for a dashboard, or writing a custom field the Change API doesn’t surface. A concrete decision guide:

If you need to… Use Endpoint
Create a Normal change that enters approval Change API POST /api/sn_chg_rest/change
Create a pre-approved standard change Change API POST /api/sn_chg_rest/change/standard/{template_sys_id}
Create an emergency change Change API POST /api/sn_chg_rest/change/emergency
Read a change’s state/approval for the gate Change API GET /api/sn_chg_rest/change/{sys_id}
Move Implement → Review → Closed Change API PATCH /api/sn_chg_rest/change/{sys_id}
Find a standard template’s sys_id by name Table API GET /api/now/table/std_change_producer_version?...
Attach a test-results file Attachment API POST /api/now/attachment/file?table_name=change_request&table_sys_id={sys_id}
Bulk-export closed changes for audit Table API GET /api/now/table/change_request?sysparm_query=...
Write a custom column the Change API omits Table API PATCH /api/now/table/change_request/{sys_id}

The Change API also exposes helper sub-resources you will reach for: the change models endpoint, the conflict detection and schedule helpers, and a /change/{sys_id}/task resource for change tasks (the sub-steps of a change). You do not need all of them for a deploy gate, but knowing they exist stops you from re-implementing them over the Table API.

The three change types, end to end

The type you pick determines the approval path, and therefore the speed and the audit posture. Get this mapping right and the rest of the automation is plumbing.

Standard changes — the pre-approved fast path

A standard change is a change that is so routine and low-risk that CAB pre-approves the entire class of it once, as a template, and every instance created from that template is born already approved. This is the workhorse for continuous delivery: a well-worn application deploy, a feature-flag flip, a config rotation — anything reversible and repeatable — should be a standard change, so the gate clears in seconds and no human is in the deploy’s critical path.

You create one against the dedicated endpoint, passing the template’s sys_id:

# Create a STANDARD (pre-approved) change from a template.
curl -s -X POST \
  "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/standard/${TEMPLATE_SYS_ID}" \
  -H "Authorization: Bearer ${SN_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
        "short_description": "Deploy payments-web '"${SHORT_SHA}"'",
        "description": "Automated standard deploy of commit '"${GITHUB_SHA}"' (run '"${GITHUB_RUN_ID}"')."
      }' \
  | jq '.result | {number: .number.value, state: .state.display_value, approval: .approval.value}'

Because the template supplied the type, risk, category, and pre-approval, the returned record comes back with approval = approved almost immediately — your gate job clears on the first poll. You do not send type or risk; the template owns them. The catch: you must have a template, it must be authored and CAB-approved, and you must know its sys_id. Finding it is a Table API read:

# Look up a standard change template's sys_id by its human name.
curl -s -G \
  "https://${SN_INSTANCE}.service-now.com/api/now/table/std_change_producer_version" \
  -H "Authorization: Bearer ${SN_TOKEN}" \
  --data-urlencode "sysparm_query=nameLIKEPayments Web Deploy^active=true" \
  --data-urlencode "sysparm_fields=sys_id,name,short_description" \
  | jq '.result'

What actually makes a change eligible to be standard — the criteria CAB uses to bless a template — and the trade-off of putting a deploy on this path:

Criterion for “standard” Why it matters If it fails
Low risk, low impact The whole point — no per-instance review Should be Normal instead
Well-understood, documented procedure The template is the procedure Can’t template an ad-hoc change
Reversible / has a proven backout Failure is recoverable without CAB Keep it Normal so a human weighs the blast radius
Repeated frequently Templating pays off One-offs aren’t worth a template
No config or schema changes with wide blast radius Blast radius stays contained Schema/infra changes stay Normal
Proven success history CAB trusts the class New procedures start Normal, graduate to Standard

Normal changes — assessed and approved per instance

A Normal change is the default for anything with real blast radius: a new service, a database schema migration, an infrastructure change, anything not (yet) trusted enough to be standard. Each instance is assessed (risk, impact, conflict with other changes, the schedule) and approved individually — by a CAB meeting, a delegated peer approver, or an automated risk rule. This is where the human gate lives.

# Create a NORMAL change that enters the approval workflow.
PREV_SHA=$(git rev-parse HEAD~1)
BODY=$(jq -n \
  --arg short "Deploy payments-platform ${SHORT_SHA}" \
  --arg desc  "Automated deploy of commit ${GITHUB_SHA} (run ${GITHUB_RUN_ID})." \
  --arg impl  "CI/CD run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
  --arg back  "Re-run pipeline pinned to ${PREV_SHA}; Argo CD rollback to prior sync." \
  --arg test  "CI test suite passed (see attached junit.xml); staging smoke tests green." \
  '{short_description:$short,
    description:$desc,
    implementation_plan:$impl,
    backout_plan:$back,
    test_plan:$test,
    type:"normal",
    risk:"moderate",
    impact:"3",
    category:"Software",
    assignment_group:"DevOps CAB"}')

curl -s -X POST \
  "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change" \
  -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
  -d "$BODY" | jq '.result | {number:.number.value, state:.state.display_value, approval:.approval.value}'

The fields a Normal change should carry from the pipeline — because the pipeline already knows them and an auditor will read them:

Field What it holds Source in the pipeline Why it matters
short_description One-line summary Deploy <app> <shortSHA> The record’s headline
description Full context Commit, run ID, PR link The “what and why”
implementation_plan How the change is made Link to the pipeline run Proves a documented procedure
backout_plan How to reverse it Prior SHA, rollback command The single field ops relies on at 2 a.m.
test_plan How it was/will be validated Test-suite summary Evidence the change was tested
type normal Pipeline logic Selects the approval path
risk low/moderate/high Risk rule or deploy metadata Drives which approvers are needed
impact 1(high)–3(low) Service tier Feeds risk/priority
assignment_group The owning group / CAB Team config Routes the approval
cmdb_ci The affected Configuration Item Service catalog mapping Links the change to what it touches
start_date / end_date The change window Deploy schedule Conflict detection + audit

Normal changes can be approved three ways, and knowing which your instance uses tells you what the gate is actually waiting on:

Approval mechanism Who/what approves Latency When it’s used
CAB meeting The board, on a schedule Hours–days High-risk, wide-impact changes
Delegated / peer approval A named approver or group Minutes–hours Medium risk on trusted teams
Automated risk-based approval A workflow rule (risk = low → auto) Seconds Low-risk Normal changes; DevOps Change Velocity
Change model approval policy The model’s approval definition Varies Modern instances using Change Models

Emergency changes — the expedited break-fix

An emergency change is for urgent, unplanned fixes — a P1 incident remediation, a security hotfix that cannot wait for the next CAB. Approval is expedited (an e-CAB, an on-call manager, or an emergency approval group), often after the fact, and a post-implementation review (PIR) is mandatory. From a pipeline you create it against the emergency endpoint:

curl -s -X POST \
  "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/emergency" \
  -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
  -d '{"short_description":"EMERGENCY hotfix for INC0042100 — payment auth failure",
       "description":"Deploy '"${SHORT_SHA}"' to restore payment authorization. Linked to INC0042100.",
       "risk":"high","impact":"1"}' \
  | jq '.result | {number:.number.value, state:.state.display_value}'

The three types side by side — this is the table to memorise:

Dimension Standard Normal Emergency
Risk Low Any Usually high (but urgent)
Approval Pre-approved (template) Per-instance (CAB/peer/auto) Expedited (e-CAB/on-call)
Speed Seconds Minutes–days Minutes
Endpoint /change/standard/{tpl} /change /change/emergency
Human in the deploy path? No Yes (usually) Yes (expedited)
Post-review required? No No (assessed up front) Yes (PIR)
Pipeline use case Routine deploys, config flips New services, migrations Incident hotfixes
Audit posture Template approved once, instances inherit Full per-change assessment Retrospective justification

The change state model you must not hard-code blindly

The Change API drives a state model: a change moves through numeric states, and the API enforces which transitions are legal. The default out-of-box model uses these values — but many instances customise them, so the golden rule is: read one record’s state.display_value on your instance and map the numbers before you hard-code them.

The default state values (Vancouver+), with the field name your automation writes:

state.value state.display_value Meaning Who moves it there
-5 New Just created, not yet assessed Create
-4 Assess Being risk-assessed Assignment / workflow
-3 Authorize Awaiting approval Workflow (this is where the gate waits)
-2 Scheduled Approved, scheduled for its window After approval
-1 Implement Being implemented now The deploy job (pipeline)
0 Review Implemented, under post-review The deploy job on completion
3 Closed Complete, with an outcome The deploy job on close
4 Cancelled Abandoned Manual / on abort

The approval field is separate from state and is what the gate actually polls, because a change can be in Authorize with approval still requested:

approval.value Meaning Gate action
not requested Approval not yet sought Keep waiting (or the workflow hasn’t fired)
requested Sitting with the approver(s) Keep polling — this is the normal “waiting on CAB” state
approved Signed off Proceed to deploy
rejected Declined Fail the run — do not deploy
duplicate Superseded by another change Fail / investigate

Confirm your instance’s mapping before trusting any numbers:

# Read one change and print BOTH the raw value and the human label for state + approval.
curl -s "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
  -H "Authorization: Bearer ${SN_TOKEN}" \
  | jq '.result | {state_value:.state.value, state_label:.state.display_value,
                   approval_value:.approval.value, approval_label:.approval.display_value}'

Why this matters: a shop that renamed or renumbered states via the Change Management — State Model will have your "state":"3" (Closed by default) mean something else, and your close step will either fail the transition or land the change in the wrong state. Read once, map once, then encode your instance’s values.

Authenticating the pipeline: OAuth, not a password in CI

The pipeline must authenticate as a machine, not a person, and the credential must be short-lived and revocable. ServiceNow speaks standard OAuth 2.0, exposing token endpoints at /oauth_token.do. You create an OAuth API endpoint for external clients (System OAuth → Application Registry → Create an OAuth API endpoint for external clients), which yields a Client ID and Client Secret, and you create a dedicated integration user with only the roles it needs.

There are two grant flows that matter here, and choosing the right one is a security decision:

Grant How it works Secret footprint When to use
Password grant Client ID + secret and a service-account username/password → token Client secret and a service password Simple, widely supported; but you hold a password
Client credentials Client ID + secret only → token bound to a service account Client secret only (no user password) Preferred — smaller footprint; requires the OAuth entity mapped to a user
JWT bearer Signed JWT asserts identity → token A private key Highest assurance; more setup

Verify the OAuth client works by minting a token from your laptop (never from the pipeline) using the password grant:

# One-time check from your laptop, NOT the pipeline.
SN_INSTANCE="dev-acme"   # https://dev-acme.service-now.com
curl -s -X POST "https://${SN_INSTANCE}.service-now.com/oauth_token.do" \
  -d "grant_type=password" \
  -d "client_id=${SN_CLIENT_ID}" \
  -d "client_secret=${SN_CLIENT_SECRET}" \
  -d "username=svc.cicd.github" \
  -d "password=${SN_SVC_PASSWORD}" | jq -r '.access_token'

A JWT-looking string means the OAuth app, the integration user, and the roles all line up. The two errors you will hit, and exactly what each means:

OAuth error HTTP Meaning Fix
invalid_client 401 Client ID/secret pair is wrong or the OAuth entity is inactive Re-copy the secret; confirm the Application Registry record is Active
access_denied / invalid_grant 401 Username/password wrong, or the user lacks a required role Reset the service password; grant sn_change_write + itil
unauthorized_client 400 Grant type not enabled on the OAuth entity Enable the grant type on the Application Registry record
server_error 500 Instance-side issue (rare) Check instance logs; retry

The token’s lifetime is the reason to prefer OAuth over Basic auth, and it drives a critical pipeline design rule:

Token property Default Why it matters
Access-token lifetime 30 minutes A CAB wait longer than 30 min outlives a token minted in job 1 — re-mint per job
Refresh-token lifetime ~100 days (configurable) Long-lived; keep it out of CI, use the client-credentials/password flow to get a fresh access token instead
Revocability Per OAuth client A leaked secret is revoked by deactivating one Application Registry record

Why OAuth and not Basic auth: ServiceNow access tokens are short-lived (30 minutes by default) and revocable per client, so a leaked pipeline credential has a small blast radius — unlike a long-lived password embedded in CI. You never put the user password in GitHub; only the OAuth client secret leaves Vault, and even that is exchanged for a 30-minute token the moment the job starts.

Storing the credential in Vault (and the fallback)

Put the OAuth client secret (and the service-account password, if you use the password grant) behind HashiCorp Vault so it is leased at runtime and never sits in GitHub. Write it to a KV v2 mount:

vault kv put secret/cicd/servicenow \
  client_id="${SN_CLIENT_ID}" \
  client_secret="${SN_CLIENT_SECRET}" \
  svc_username="svc.cicd.github" \
  svc_password="${SN_SVC_PASSWORD}"

Bind a policy that grants read-only access to only that path, and map it to the GitHub OIDC auth role so only workflows from this one repo and branch can read it:

# servicenow-read.hcl
path "secret/data/cicd/servicenow" {
  capabilities = ["read"]
}
vault policy write servicenow-read servicenow-read.hcl

# Allow ONLY this repo's Actions workflows on main to assume the policy via OIDC.
vault write auth/jwt/role/github-acme-deploy \
  role_type="jwt" \
  bound_audiences="https://github.com/acme" \
  bound_claims_type="glob" \
  bound_claims='{"repository":"acme/payments-platform","ref":"refs/heads/main"}' \
  user_claim="repository" \
  policies="servicenow-read" \
  ttl="15m"

If you do not run Vault, store SN_CLIENT_ID / SN_CLIENT_SECRET / SN_SVC_PASSWORD as GitHub Encrypted Secrets scoped to the production environment instead — the workflow reads them the same way, just from secrets.* rather than a Vault step output. The trade-offs between the two secret sources:

Aspect Vault (leased via OIDC) GitHub Encrypted Secrets
Where the secret lives Central Vault, leased at job start GitHub, encrypted at rest
Rotation Rotate once in Vault, all pipelines follow Rotate in every repo/environment
Blast radius of a leak Bound to repo+branch by OIDC role Bound to repo/environment scope
Audit trail Vault audit log of every lease GitHub audit (coarser)
Extra infrastructure Requires Vault None
Best for Many pipelines, central secret governance A single repo, no Vault available

For a deeper treatment of leasing secrets into pipelines, see Managing pipeline secrets with Key Vault and managed identity and the central-broker pattern in HashiCorp Vault as a central secrets broker across clouds.

The gate pattern: open → await → deploy → attach → close

This is the heart of the guide. The pipeline is one deploy split into jobs that share the sys_id, and the shape is always the same regardless of platform:

Stage Job What it does Change API call
1. Open open-change Auth, create the change stamped with the commit, emit sys_id + number POST /change or /change/standard/{tpl}
2. Await await-approval Poll approval until approved (or fail on rejected/timeout) — the hard gate GET /change/{sys_id}
3. Deploy deploy Move to Implement, run the real rollout PATCH /change/{sys_id} (state -1)
4. Attach deploy Upload test results, artefacts, scan reports as evidence POST /api/now/attachment/file
5. Close deploy Move to Review → Closed with a truthful close_code PATCH /change/{sys_id} (state 3)

GitHub Actions: the full workflow

Create .github/workflows/deploy.yml. The permissions: id-token: write line is what lets the runner do OIDC to Vault.

name: Deploy with ServiceNow change control
on:
  push:
    branches: [main]

permissions:
  id-token: write      # required for Vault / cloud OIDC
  contents: read

jobs:
  open-change:
    runs-on: ubuntu-latest
    outputs:
      sys_id: ${{ steps.create.outputs.sys_id }}
      number: ${{ steps.create.outputs.number }}
    steps:
      - uses: actions/checkout@v4

      - name: Import ServiceNow secret from Vault
        id: vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.acme.internal:8200
          method: jwt
          role: github-acme-deploy
          secrets: |
            secret/data/cicd/servicenow client_id     | SN_CLIENT_ID ;
            secret/data/cicd/servicenow client_secret | SN_CLIENT_SECRET ;
            secret/data/cicd/servicenow svc_username  | SN_SVC_USER ;
            secret/data/cicd/servicenow svc_password  | SN_SVC_PASS

      - name: Mint ServiceNow OAuth token
        id: token
        env:
          SN_INSTANCE: dev-acme
        run: |
          TOKEN=$(curl -s -X POST \
            "https://${SN_INSTANCE}.service-now.com/oauth_token.do" \
            -d "grant_type=password" \
            -d "client_id=${SN_CLIENT_ID}" \
            -d "client_secret=${SN_CLIENT_SECRET}" \
            -d "username=${SN_SVC_USER}" \
            -d "password=${SN_SVC_PASS}" | jq -r '.access_token')
          if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
            echo "::error::Failed to mint ServiceNow OAuth token (check client/creds)."; exit 1
          fi
          echo "::add-mask::$TOKEN"
          echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

      - name: Create change request
        id: create
        env:
          SN_INSTANCE: dev-acme
          SN_TOKEN: ${{ steps.token.outputs.token }}
        run: |
          PREV_SHA=$(git rev-parse HEAD~1)
          BODY=$(jq -n \
            --arg short "Deploy payments-platform ${GITHUB_SHA:0:7}" \
            --arg desc  "Automated deploy of commit ${GITHUB_SHA} (run ${GITHUB_RUN_ID})." \
            --arg impl  "CI/CD run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
            --arg back  "Re-run pipeline pinned to ${PREV_SHA}; Argo CD rollback to prior sync." \
            --arg test  "CI suite passed; junit.xml attached to this change." \
            '{short_description:$short, description:$desc,
              implementation_plan:$impl, backout_plan:$back, test_plan:$test,
              type:"normal", risk:"moderate", impact:"3",
              category:"Software", assignment_group:"DevOps CAB"}')

          RESP=$(curl -s -X POST \
            "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change" \
            -H "Authorization: Bearer ${SN_TOKEN}" \
            -H "Content-Type: application/json" \
            -d "$BODY")

          echo "$RESP" | jq '.result | {number:.number.value, state:.state.display_value}'
          SYS_ID=$(echo "$RESP" | jq -r '.result.sys_id.value')
          NUMBER=$(echo "$RESP" | jq -r '.result.number.value')
          if [ -z "$SYS_ID" ] || [ "$SYS_ID" = "null" ]; then
            echo "::error::Change creation failed: $(echo "$RESP" | jq -c '.error // .')"; exit 1
          fi
          echo "sys_id=$SYS_ID" >> "$GITHUB_OUTPUT"
          echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
          echo "Created change $NUMBER ($SYS_ID)"

Every jq selector ends in .value because the Change API wraps every field in {value, display_value}. The sys_id emitted here is the thread the rest of the pipeline follows.

For a Standard change, swap the URL for .../api/sn_chg_rest/change/standard/<template_sys_id> and drop type/risk/impact — the template supplies them, and the record is born pre-approved, so the next job clears on the first poll.

The hard gate: poll until approved

The gate is a separate job that blocks the deploy. Because it re-mints its own token, a long CAB wait never outlives the token from job 1.

  await-approval:
    needs: open-change
    runs-on: ubuntu-latest
    steps:
      - name: Import secret + mint token
        id: token
        uses: ./.github/actions/sn-token   # a small composite action wrapping Vault + oauth_token.do
        with:
          instance: dev-acme
          vault-role: github-acme-deploy

      - name: Poll until approved
        env:
          SN_INSTANCE: dev-acme
          SN_TOKEN: ${{ steps.token.outputs.token }}
          SYS_ID:   ${{ needs.open-change.outputs.sys_id }}
        run: |
          for i in $(seq 1 120); do          # up to ~60 min at 30s cadence
            APPROVAL=$(curl -s \
              "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
              -H "Authorization: Bearer ${SN_TOKEN}" \
              | jq -r '.result.approval.value')
            echo "Attempt $i: approval=${APPROVAL}"
            case "$APPROVAL" in
              approved)  echo "Change approved — proceeding."; exit 0 ;;
              rejected)  echo "::error::Change was REJECTED in ServiceNow."; exit 1 ;;
              duplicate) echo "::error::Change marked DUPLICATE."; exit 1 ;;
              *)         sleep 30 ;;
            esac
          done
          echo "::error::Timed out waiting for CAB approval."; exit 1

Because deploy declares needs: await-approval, GitHub will not start the rollout until this job exits 0. A rejection or timeout fails the run and the deploy never happens — the gate is a hard fence, not a warning. The poll must branch on rejected explicitly, or a naïve “only check for approved” loop spins to timeout on a declined change.

Deploy, attach evidence, close honestly

The final job runs the real deploy, attaches evidence, and drives the change to Closed — as successful or unsuccessful, truthfully.

  deploy:
    needs: [open-change, await-approval]
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Import secret + mint token
        id: token
        uses: ./.github/actions/sn-token
        with: { instance: dev-acme, vault-role: github-acme-deploy }

      - name: Move change to Implement
        env: { SN_INSTANCE: dev-acme, SN_TOKEN: "${{ steps.token.outputs.token }}", SYS_ID: "${{ needs.open-change.outputs.sys_id }}" }
        run: |
          curl -s -X PATCH \
            "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
            -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
            -d '{"state":"-1","work_notes":"Deploy started from GitHub Actions run '"${GITHUB_RUN_ID}"'."}' > /dev/null

      - name: Run the actual deploy
        id: rollout
        run: |
          terraform -chdir=infra init -input=false
          terraform -chdir=infra apply -auto-approve -input=false
          # ...or Ansible / Argo CD sync / kubectl rollout, as your stack dictates.

      - name: Attach test results as evidence
        if: always()
        env: { SN_INSTANCE: dev-acme, SN_TOKEN: "${{ steps.token.outputs.token }}", SYS_ID: "${{ needs.open-change.outputs.sys_id }}" }
        run: |
          if [ -f junit.xml ]; then
            curl -s -X POST \
              "https://${SN_INSTANCE}.service-now.com/api/now/attachment/file?table_name=change_request&table_sys_id=${SYS_ID}&file_name=junit.xml" \
              -H "Authorization: Bearer ${SN_TOKEN}" \
              -H "Content-Type: application/xml" \
              --data-binary @junit.xml | jq '.result.sys_id'
          fi

      - name: Close change (success)
        if: success()
        env: { SN_INSTANCE: dev-acme, SN_TOKEN: "${{ steps.token.outputs.token }}", SYS_ID: "${{ needs.open-change.outputs.sys_id }}", NUMBER: "${{ needs.open-change.outputs.number }}" }
        run: |
          curl -s -X PATCH \
            "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
            -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
            -d '{"state":"3","close_code":"successful",
                 "close_notes":"Deploy of '"${GITHUB_SHA:0:7}"' completed via run '"${GITHUB_RUN_ID}"'."}' \
            | jq '.result.state'
          echo "Closed ${NUMBER} as successful."

      - name: Close change (failure)
        if: failure()
        env: { SN_INSTANCE: dev-acme, SN_TOKEN: "${{ steps.token.outputs.token }}", SYS_ID: "${{ needs.open-change.outputs.sys_id }}" }
        run: |
          curl -s -X PATCH \
            "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
            -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
            -d '{"state":"3","close_code":"unsuccessful",
                 "close_notes":"Deploy FAILED — backout executed. See GitHub run for logs."}' > /dev/null

The if: success() / if: failure() pair guarantees the record is always closed with a truthful code, even when the rollout blows up mid-apply — the single behaviour that keeps the change log and production from drifting. The valid close_code values, and when each is honest:

close_code Use when What it signals to audit
successful Deploy completed, all validations passed The change achieved its intent cleanly
successful_with_issues Deployed but with minor deviations Worked, but note the caveats in close_notes
unsuccessful Deploy failed / rolled back The change did not achieve its intent

Attaching artefacts and test results as evidence

A change record without evidence is a title an auditor cannot verify. The Attachment API (/api/now/attachment) uploads files against any record; for a change you target table_name=change_request and the change’s sys_id. Two forms exist:

Endpoint Body Use for
POST /api/now/attachment/file?table_name=…&table_sys_id=…&file_name=… Raw file bytes (--data-binary @file) A single file (junit.xml, a scan report, a plan.txt)
POST /api/now/attachment/upload multipart/form-data Multipart uploads / form-style

A concrete example attaching several evidence artefacts the pipeline already produced:

# Attach a Terraform plan, the JUnit results, and a Trivy scan report to the change.
for f in tfplan.txt junit.xml trivy-report.json; do
  [ -f "$f" ] || continue
  ctype="text/plain"
  case "$f" in *.xml) ctype="application/xml";; *.json) ctype="application/json";; esac
  curl -s -X POST \
    "https://${SN_INSTANCE}.service-now.com/api/now/attachment/file?table_name=change_request&table_sys_id=${SYS_ID}&file_name=${f}" \
    -H "Authorization: Bearer ${SN_TOKEN}" \
    -H "Content-Type: ${ctype}" \
    --data-binary @"$f" | jq -r '.result.file_name + " -> " + .result.sys_id'
done

What to attach, and why an auditor wants it:

Artefact Where it comes from What it proves
Test results (junit.xml) CI test stage The change was tested and passed
Terraform plan (tfplan.txt) terraform plan Exactly what infra changed
Security scan (trivy-report.json) Container/IaC scanning The artefact met the security bar
SBOM / provenance Supply-chain step What shipped and how it was built
Approval evidence Change record itself Who approved and when (already on the record)
Deploy log link Pipeline run URL The full execution trail

Note the file-size and count limits: ServiceNow caps attachment size by the com.glide.attachment.max_size property (default 1024 MB, often tuned lower). For large logs, attach a link to the pipeline run rather than the raw multi-gigabyte log, and keep the record light.

DevOps Change Velocity: the zero-code path

If hand-rolling curl and jq feels like a lot, ServiceNow ships an app that does most of this for you: DevOps Change Velocity (the current name; earlier releases called it DevOps Change Acceleration, and its core is what was DevOps Change). It connects to your GitHub Actions, Azure DevOps, Jenkins, or GitLab pipeline, ingests build/test/security telemetry, and automatically creates a change from a pipeline step and auto-approves it when your change policy passes — no bespoke API calls in your YAML.

How it works: you install the app, register your tool (the GitHub/ADO connection), map a pipeline step as the change-control gate, and define a DevOps change policy (the conditions — tests passed, coverage threshold, no critical vulns — under which a change is auto-approved). The pipeline calls a small callback (or the app polls the tool), the app raises a Normal change enriched with the pipeline’s telemetry, evaluates the policy, and either auto-approves (policy met → deploy proceeds) or routes to a human (policy not met → normal approval). It closes the change from the pipeline’s success/failure signal.

When to use the app versus a hand-rolled integration:

Factor DevOps Change Velocity (app) Hand-rolled Change API
Setup Install + connect tool + define policy (no code) Write and maintain YAML + API calls
Telemetry enrichment Rich, automatic (commits, tests, artefacts, pipelines) You attach what you code
Auto-approval policy Declarative policy engine in ServiceNow You build the risk logic yourself
Licensing Requires the DevOps Change Velocity subscription Uses base ITSM you already own
Flexibility Opinionated to its model Total — any field, any flow
Best for Standardising many pipelines at scale Bespoke gates, unusual flows, no extra license
Maintenance ServiceNow maintains the app You maintain the scripts

The two approaches are not exclusive: teams often start hand-rolled to learn the mechanics (as this guide does), then adopt DevOps Change Velocity to standardise across dozens of pipelines. If you already pay for it, prefer it for the common case and drop to raw API for the exceptions.

Porting the gate to Azure DevOps

The same pattern maps cleanly to Azure DevOps pipelines. The two platform-specific differences are (1) secrets come from a variable group (optionally linked to Azure Key Vault) instead of Vault-via-OIDC, and (2) the human gate can be an Azure DevOps Environment approval in addition to — or the poll can drive — the ServiceNow gate. There is also a ServiceNow Change Management extension in the Azure DevOps Marketplace that provides a native “gate” task, but the raw API port below has no extra dependency.

# azure-pipelines.yml — open → gate → deploy → close against the ServiceNow Change API
trigger:
  branches: { include: [ main ] }

variables:
  - group: servicenow-oauth        # holds SN_CLIENT_ID, SN_CLIENT_SECRET, SN_SVC_USER, SN_SVC_PASS
  - name: SN_INSTANCE
    value: dev-acme

stages:
  - stage: OpenChange
    jobs:
      - job: open
        steps:
          - bash: |
              TOKEN=$(curl -s -X POST "https://$(SN_INSTANCE).service-now.com/oauth_token.do" \
                -d grant_type=password -d client_id=$(SN_CLIENT_ID) -d client_secret=$(SN_CLIENT_SECRET) \
                -d username=$(SN_SVC_USER) -d password=$(SN_SVC_PASS) | jq -r '.access_token')
              echo "##vso[task.setvariable variable=SN_TOKEN;issecret=true]$TOKEN"
            displayName: Mint OAuth token
          - bash: |
              RESP=$(curl -s -X POST "https://$(SN_INSTANCE).service-now.com/api/sn_chg_rest/change" \
                -H "Authorization: Bearer $(SN_TOKEN)" -H "Content-Type: application/json" \
                -d '{"short_description":"Deploy '"$(Build.SourceVersion)"'","type":"normal","risk":"moderate",
                     "description":"ADO run '"$(Build.BuildId)"'","assignment_group":"DevOps CAB"}')
              SYS_ID=$(echo "$RESP" | jq -r '.result.sys_id.value')
              echo "##vso[task.setvariable variable=SYS_ID;isOutput=true]$SYS_ID"
            name: create
            displayName: Create change request

  - stage: Deploy
    dependsOn: OpenChange
    variables:
      SYS_ID: $[ stageDependencies.OpenChange.open.outputs['create.SYS_ID'] ]
    jobs:
      - deployment: deploy
        environment: production        # attach an ADO Environment "approval" here for the human gate
        strategy:
          runOnce:
            deploy:
              steps:
                - bash: |
                    # Re-mint token, poll approval, deploy, then PATCH close — same calls as the Actions version.
                    echo "Deploying against change $(SYS_ID)"
            on:
              failure:
                steps:
                  - bash: echo "Close change $(SYS_ID) as unsuccessful via PATCH"

The GitHub Actions vs Azure DevOps mapping, so you can port either way:

Concept GitHub Actions Azure DevOps
Secret source Vault-action (OIDC) / Encrypted Secrets Variable group / Key Vault-linked group
Pass value between jobs outputs + needs isOutput=true + stageDependencies
Mask a secret in logs ::add-mask:: ##vso[task.setvariable …;issecret=true]
Hard gate on a job needs: await-approval dependsOn + deployment environment
Native human approval Environment protection rule (reviewers) Environment approvals & checks
Deploy job type job in environment deployment job with strategy
Emit an error ::error:: ##vso[task.logissue type=error]

Architecture at a glance

Read the diagram left to right; it is the pipeline you just built, with each numbered badge marking a key step or the failure that bites there. On the far left, a push to main in the GitHub repo kicks the run. The CI/CD runner hosts three jobs: open-change mints a 30-minute OAuth token — the client secret leased from Vault via OIDC, never stored in CI (badge 2, where a wrong secret shows as invalid_client) — and POSTs the Change API to create the CHG record, emitting its sys_id and number (badge 1, and badge 3 warns that a Normal body sent to the /change/standard endpoint returns 400). The await-approval job is the hard gate (badge 4): because deploy declares needs: await-approval, the rollout cannot start until approval = approved.

In the centre sits ServiceNow as the system of record — the CHG0031245 record, driven through the sn_chg_rest Change API. Above it, Entra / Okta provides SSO + MFA so a real CAB approver signs off in the ServiceNow UI (badge 5, the separation-of-duties gate — the pipeline never logs in as a person, and the integration user must be barred from self-approval or the whole control collapses). On the right, the deploy stage runs the real rollout — Terraform apply, an Argo CD sync — tags a deploy event onto the observability timeline, then PATCHes the change New → Implement → Review → Closed (badge 6), where the if:success() / if:failure() pair always closes with a truthful close_code so the log never drifts from production. The arrows narrate the flow: triggerPOST /changeapproveapprovedgate openPATCH close.

Automating ServiceNow change control from a GitHub Actions pipeline — a push to main triggers a CI/CD runner whose open-change job mints a 30-minute OAuth token with the client secret leased from HashiCorp Vault via OIDC and POSTs the sn_chg_rest Change API to create change CHG0031245, an await-approval job that hard-gates the deploy until approval equals approved, a human CAB approver signing off in ServiceNow through Entra or Okta SSO with MFA to preserve separation of duties, and a deploy stage running Terraform apply and Argo CD sync before PATCHing the change through New, Implement, Review and Closed with a truthful close_code — with six numbered badges marking create, Vault lease, standard-vs-normal endpoint, the hard gate, human approval, and honest close

Real-world scenario

Meridian Pay runs a card-issuing platform on Kubernetes, deployed via GitHub Actions and Argo CD, with ServiceNow ITSM as the mandated system of record under a PCI-DSS and SOC 2 regime. The platform team is six engineers owning nineteen microservices; before this project they shipped about fifty production deploys a week, and every one required a hand-raised change. The change manager, Priya, spent roughly a day a week reconciling drift: changes left open, changes closed “successful” that had actually rolled back, and — the finding that triggered the whole effort — a SOC 2 audit that flagged 14% of Q1 production changes as having no linked approval record, because engineers under deadline had deployed first and back-dated the change.

The first design decision was the type mapping. The team audited a month of deploys and found that 41 of 50 weekly deploys were routine, reversible application rollouts — perfect standard-change candidates — while the remaining ~9 were schema migrations, new services, or infra changes that genuinely warranted CAB. So they built two paths: routine service deploys create a standard change from a CAB-blessed template (Payments Service Rolling Deploy) and clear the gate in under 8 seconds; anything touching the database or a new CI creates a Normal change routed to a peer approver (not the weekly CAB — a delegated approval that clears in minutes for low-risk Normal changes, escalating to full CAB only for risk = high).

The rollout hit two instructive snags. First, the initial poll loop only checked for approved and, on a test rejection, spun for the full 60-minute timeout before failing — they fixed it by branching on rejected explicitly. Second, a customised state model bit them: the instance had renumbered Closed from 3 to a custom value, so the first week’s close PATCHes silently failed the transition and left changes stuck in Review. Reading one record’s state.display_value mapped it, and they encoded the instance’s real values. They also added the Attachment API step to bolt the junit.xml and the Argo diff onto every change — directly answering the auditor’s “prove it was tested” question.

The result after one quarter: change-log drift fell to zero (every deploy’s change auto-closed with a truthful code), the 14%-no-approval finding was fully remediated (a deploy cannot run without a change reaching approved, standard or Normal), and Priya’s reconciliation day evaporated — she now spends that time on the ~9 weekly Normal changes that actually need judgement. The engineering-time saving was real too: at ~10 minutes of manual change-wrangling per deploy across 50 deploys, the team reclaimed roughly a full engineer-day a week. The lesson on the wall: “Don’t make CAB the gate for everything — make the template the gate for the routine, and the human the gate for the risky.” The incident-and-remediation timeline that convinced leadership:

Phase State before Change made Outcome
Baseline 50 manual changes/wk, 14% missing approvals SOC 2 finding
Type mapping Everything → CAB 41/50 → standard template, ~9 → Normal Routine deploys clear in <8 s
Snag 1 Poll spun on rejection Branch on rejected Rejections fail fast
Snag 2 Close PATCH silently failed Map custom state model Changes close correctly
Evidence Titles without proof Attachment API for junit + diff Auditor’s “prove it” answered
Steady state 1 day/wk reconciliation Auto-open/close + evidence Drift = 0; finding remediated; ~1 eng-day/wk reclaimed

Advantages and disadvantages

Making the pipeline the author of the change is powerful, but it moves real governance into your YAML — weigh it honestly:

Advantages Disadvantages
The change record can never drift from production — it is the deploy, opened and closed by the same run The pipeline now owns an audit artefact; a bug in the close step can misrecord an outcome
Standard-change templates make routine deploys clear the gate in seconds — CAB is off the critical path Requires a CAB-blessed template and a governance conversation to get one
Separation of duties is preserved: machine creates, human (or template) approves Easy to break SoD by accident — one over-privileged integration user collapses the control
Evidence (tests, plans, scans) is attached automatically — audits become a link, not a hunt Attachment size limits and log volume force discipline about what you attach
OAuth short-lived tokens + Vault lease shrink credential blast radius to minutes More moving parts (OAuth entity, Vault role, OIDC binding) to set up and maintain
Works across GitHub Actions, Azure DevOps, Jenkins, GitLab with the same API Custom state models mean the numeric states aren’t portable without mapping
DevOps Change Velocity offers a zero-code path at scale That path needs an extra ServiceNow subscription
Emergency-change endpoint lets hotfixes stay compliant without slowing them Emergency changes still require a post-implementation review you must not skip

The model is right for any regulated shop shipping continuously, where the alternative — manual change raising — simply does not scale to dozens of deploys a day and produces exactly the drift and shadow-change that auditors flag. It is less worth the effort for a low-frequency, low-regulation environment where a handful of monthly changes are easily managed by hand. The disadvantages are all manageable, but only if you treat the integration user’s privileges and the state-model mapping as first-class design concerns, not afterthoughts.

Hands-on lab

Build the whole loop against a free ServiceNow Developer instance (from the ServiceNow Developer Program) and drive it with curl/jq from your shell, then wire it into a real GitHub Actions run. No production access is required, and every step tears down cleanly. This lab is the centrepiece — do it end to end before you trust the pattern on a real release.

Step 1 — Get a Developer instance and set variables. Request a Personal Developer Instance at developer.servicenow.com (free), then export its details. Use an admin login only for the one-time setup in steps 2–3.

export SN_INSTANCE="dev12345"     # your PDI subdomain: https://dev12345.service-now.com
export SN_ADMIN="admin"
export SN_ADMIN_PW="<your PDI admin password>"

Step 2 — Create the OAuth endpoint and integration user (one-time, in the UI). In the instance: System OAuth → Application Registry → New → Create an OAuth API endpoint for external clients. Name it cicd-github, save, and copy the generated Client ID and Client Secret. Then User Administration → Users → New: create svc.cicd.github, set a password, and grant it the roles sn_change_write and itil (and nothing that can approve changes). Export what you copied:

export SN_CLIENT_ID="<client id from Application Registry>"
export SN_CLIENT_SECRET="<client secret>"
export SN_SVC_USER="svc.cicd.github"
export SN_SVC_PASS="<the service user's password>"

Step 3 — Mint a token (prove auth works).

export SN_TOKEN=$(curl -s -X POST "https://${SN_INSTANCE}.service-now.com/oauth_token.do" \
  -d grant_type=password -d client_id="${SN_CLIENT_ID}" -d client_secret="${SN_CLIENT_SECRET}" \
  -d username="${SN_SVC_USER}" -d password="${SN_SVC_PASS}" | jq -r '.access_token')
echo "${SN_TOKEN:0:12}..."   # a token prefix means success; empty/null means auth failed

Expected: a non-empty token prefix. If empty, re-check the client secret (invalid_client) or the user’s roles (access_denied).

Step 4 — Create a Normal change and capture its sys_id.

RESP=$(curl -s -X POST "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change" \
  -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
  -d '{"short_description":"LAB deploy demo",
       "description":"Hands-on lab change","type":"normal","risk":"low","impact":"3",
       "implementation_plan":"Lab run","backout_plan":"None — lab only"}')
echo "$RESP" | jq '.result | {number:.number.value, sys_id:.sys_id.value, state:.state.display_value, approval:.approval.value}'
export SYS_ID=$(echo "$RESP" | jq -r '.result.sys_id.value')
export NUMBER=$(echo "$RESP" | jq -r '.result.number.value')
echo "Created $NUMBER ($SYS_ID)"

Expected: a CHG00… number, a sys_id, state = New/Assess, approval = not requested/requested.

Step 5 — Read it back and confirm the state model on YOUR instance.

curl -s "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
  -H "Authorization: Bearer ${SN_TOKEN}" \
  | jq '.result | {state_value:.state.value, state_label:.state.display_value,
                   approval_value:.approval.value, approval_label:.approval.display_value}'

Note the numeric state_value that maps to each label — you will encode these numbers, not the defaults, if your PDI differs.

Step 6 — Approve it as a human (simulate the CAB gate). In the instance UI, open the change (Change → All, find your CHG number), set State to Assess/Authorize if needed so the approval fires, then as an approver (the admin acts as one on a PDI) approve it via the Approvers related list. Now poll from the shell and watch it flip:

for i in $(seq 1 10); do
  A=$(curl -s "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
      -H "Authorization: Bearer ${SN_TOKEN}" | jq -r '.result.approval.value')
  echo "poll $i: approval=$A"
  [ "$A" = "approved" ] && { echo "APPROVED — gate would open"; break; }
  [ "$A" = "rejected" ] && { echo "REJECTED — gate blocks"; break; }
  sleep 5
done

Expected: within a few polls, approval=approved and “gate would open”. This is exactly the gate the pipeline enforces.

Step 7 — Move to Implement, attach evidence, then close successfully.

# Move to Implement
curl -s -X PATCH "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
  -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
  -d '{"state":"-1","work_notes":"Lab deploy starting"}' | jq '.result.state'

# Attach a fake test-results file as evidence
echo '<testsuite tests="12" failures="0"/>' > junit.xml
curl -s -X POST \
  "https://${SN_INSTANCE}.service-now.com/api/now/attachment/file?table_name=change_request&table_sys_id=${SYS_ID}&file_name=junit.xml" \
  -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/xml" \
  --data-binary @junit.xml | jq '.result | {file_name, sys_id}'

# Close successfully
curl -s -X PATCH "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
  -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
  -d '{"state":"3","close_code":"successful","close_notes":"Lab deploy complete"}' \
  | jq '.result | {state:.state.display_value, close_code:.close_code.value}'

Expected: state moves to Implement, the attachment returns a sys_id, and the final read shows Closed / successful. Open the change in the UI — the junit.xml is in the Attachments and the close notes are set.

Step 8 — Test the rejection path. Create a second change (repeat step 4), and this time reject it in the UI. The poll from step 6 should print REJECTED — gate blocks and, in a real pipeline, fail the run before any deploy.

Step 9 — Wire it into GitHub Actions (optional, real run). Put SN_CLIENT_ID, SN_CLIENT_SECRET, SN_SVC_USER, SN_SVC_PASS as Encrypted Secrets on a test repo’s production environment, drop in the deploy.yml from earlier (replace the deploy step with echo "would deploy"), and push a no-op commit. The run pauses at await-approval; approve in the PDI; the run turns green and “closes” the change. This is the whole pattern, live.

Step 10 — Teardown. Nothing to delete on the free PDI beyond housekeeping, but clean up to leave it tidy:

# Close/cancel any open lab changes (cancel = state 4)
curl -s -X PATCH "https://${SN_INSTANCE}.service-now.com/api/sn_chg_rest/change/${SYS_ID}" \
  -H "Authorization: Bearer ${SN_TOKEN}" -H "Content-Type: application/json" \
  -d '{"state":"4","close_code":"unsuccessful","close_notes":"Lab teardown"}' > /dev/null

# Deactivate the OAuth client (System OAuth → Application Registry → set Active=false)
# Remove the integration user or clear its roles if this PDI is shared
rm -f junit.xml
echo "Lab torn down."

A PDI hibernates after inactivity and can be reset entirely from the Developer portal if you want a clean slate.

Common mistakes & troubleshooting

The failure modes are specific and repeatable. Match your symptom, confirm it, and apply the fix:

# Symptom Root cause Confirm Fix
1 Conditionals misfire; state/approval look like objects Reading .result.state instead of .result.state.value curl … | jq '.result.state' prints {value,display_value} End every selector in .value
2 POST to the standard endpoint returns 400 Sent a Normal body (type/risk) to /change/standard/{tpl} Response .error.message is generic Standard = /change/standard/{tpl} (no type/risk); Normal = /change
3 Token works in job 1, fails later with 401 Access token expired (~30 min) mid–CAB-wait 401 on the poll/close after a long approval Re-mint the token at the start of each job
4 invalid_client on token mint Wrong client ID/secret, or OAuth entity inactive oauth_token.do returns invalid_client Re-copy the secret; set Application Registry record Active
5 access_denied on token mint Service user lacks a role or password wrong oauth_token.do returns access_denied Grant sn_change_write + itil; reset password
6 Poll spins to timeout on a rejected change Loop only checks for approved Change shows approval=rejected in UI while job still polls Branch on rejected (and duplicate) explicitly → fail fast
7 Close PATCH “succeeds” but change stuck in Review Custom state model — 3 isn’t Closed on this instance state.display_value after PATCH isn’t “Closed” Read the instance’s real state values; encode those
8 Change auto-approves — audit flags SoD break Integration user has an approver role Integration user can approve its own changes Strip any approval role; machine creates only, human/template approves
9 Deploy runs even though change wasn’t approved deploy doesn’t need the gate job Job graph shows deploy not depending on await-approval Add needs: await-approval (Actions) / dependsOn (ADO)
10 Attachment returns 400/413 File too large, or wrong table_name/table_sys_id .error or a 413 status Check com.glide.attachment.max_size; verify params; attach a link for huge logs
11 Every poll returns not requested forever Approval workflow never fired (change not in an approving state) state stays New/Assess; no approval records Ensure the change enters Authorize; check the model’s approval policy
12 429 / throttling under many concurrent pipelines Inbound REST rate limit hit by tight polling ServiceNow returns 429; detector shows rate-limit Widen poll cadence (30 s+); prefer webhook callback over busy-poll
13 Standard change still waits for approval Template isn’t actually pre-approved, or wrong template sys_id Created change shows approval=requested Fix the template’s pre-approval; confirm the sys_id via Table API
14 Token leaks into logs Forgot to mask the minted token Token string visible in run logs ::add-mask:: (Actions) / issecret=true (ADO) on every token

A decision table for the “is it auth, endpoint, or process?” triage:

If you see… It’s probably… Do this
invalid_client / access_denied at token mint Auth (client or user) Fix the OAuth entity / user roles
400 on create Endpoint/body mismatch Match body to Standard vs Normal endpoint
401 mid-pipeline Expired token Re-mint per job
Gate never opens Approval workflow / state Confirm the change reached Authorize; check approval policy
Change closes in the wrong state Custom state model Map and encode the instance’s real values
Deploy ran without approval Missing job dependency Add the needs/dependsOn gate

Best practices

Security notes

The pipeline authenticates as a dedicated, least-privileged integration user (sn_change_write + itil, nothing more) over OAuth with short-lived tokens, so a leaked credential expires in minutes and is revocable per client. The OAuth client secret is leased from HashiCorp Vault at job start via GitHub OIDC — no static ServiceNow secret is ever stored in GitHub, and the Vault role is bound to this one repo and branch (bound_claims). Every minted token is masked with ::add-mask:: (or issecret=true in ADO) so it never lands in logs. Crucially, human CAB approval flows through Okta/Entra SSO with MFA — the machine account can create and progress a change but the approval gate is a real person authenticating with corporate identity, preserving separation of duties. Keep the integration user out of any role that can approve changes; that single misconfiguration collapses the entire control, and an auditor will find it.

The security posture as a checklist:

Control Why How
Least-privilege integration user Limit blast radius; enforce SoD Only sn_change_write + itil; no approver role
OAuth short-lived tokens Small leak window; revocable 30-min tokens; deactivate OAuth entity to revoke
Secret leased, not stored No static secret in CI Vault + OIDC role bound to repo+branch
Token masking Prevent log leakage ::add-mask:: / issecret=true on every token
SoD on approval Pass audits (SOC 2, ITGC) Human via SSO+MFA, or a CAB-approved template
Attachment scanning Don’t attach secrets/PII Scan artefacts; attach links for large/sensitive logs
Audit trail retention Preserve evidence Never delete changes; deactivate accounts instead
IP/scoped access to the instance Reduce attack surface Restrict the OAuth entity and instance ACLs where possible

Let a posture tool (integrating Wiz Code with GitHub Actions for IaC and container gates) flag if a secret ever gets hard-coded into the workflow, and lean on secretless CI patterns (workload identity federation for secretless CI/CD) to keep even the Vault client credential out of the repo where you can.

Cost & sizing

This automation is effectively free of new infrastructure: it reuses your existing ServiceNow instance, GitHub Actions minutes you already pay for, and a Vault path. The real return is engineering time — a team doing fifty deploys a week reclaims the 10–15 minutes per deploy previously spent hand-filling and chasing change records (roughly a full engineer-day per week), while eliminating the audit-prep cost of reconciling drifted change logs. The only metered resource is runner time: the await-approval poll job consumes minutes while it waits, so for Normal changes with long CAB queues you want to avoid a tight busy-poll.

Where the (small) costs actually sit, and how to keep them near zero:

Cost driver Magnitude How to minimise
Actions runner minutes while polling Minutes per deploy on the await-approval job Widen cadence to 30 s+; switch to a webhook callback for long waits
ServiceNow inbound REST calls Negligible per deploy; matters at fleet scale Batch reads; avoid sub-10-s polling across many pipelines
DevOps Change Velocity subscription Per the ServiceNow contract Only if you adopt the app; base ITSM has no extra cost
Vault operations Negligible Reuse one KV path and one OIDC role
Attachment storage Small; capped by max_size Attach links for huge logs; keep artefacts lean
Engineering-time saved (the real number) ~1 engineer-day/week for a 50-deploy team This is the payback that justifies the build

Rough figures: for a 50-deploy-a-week team, reclaiming ~10 minutes per deploy is ~8 hours a week — at a blended senior rate that is meaningful money, dwarfing the near-zero runtime cost. The turn-a-paid-wait-into-an-event optimisation (webhook callback via repository_dispatch instead of busy-poll) cuts idle runner minutes to near zero for slow-approval Normal changes.

Interview & exam questions

  1. What’s the difference between the ServiceNow Change API and the Table API, and when do you use each? The Change API (sn_chg_rest) is workflow-aware — it enforces the state model, fires the approval workflow, understands standard templates, and refuses illegal transitions. The Table API is generic CRUD that writes any field without process logic. Use the Change API to run the change process from a pipeline; drop to the Table API only for gaps (template lookups, reporting, custom fields).

  2. Name the three ITIL change types and when each fits a deploy. Standard (pre-approved template, low-risk routine deploys, clears in seconds), Normal (assessed and approved per instance, for changes with real blast radius), and Emergency (expedited approval for urgent break-fix, with a mandatory post-implementation review).

  3. Why does every Change API field come back as {value, display_value}, and what bug does it cause? value is the stored/database form your code branches on; display_value is the human label. Reading .result.state instead of .result.state.value yields an object where you expected a string, so conditionals silently misfire — the number-one first-timer bug.

  4. How do you preserve separation of duties when a pipeline creates changes? The integration user gets only sn_change_write + itil and no approver role, so it can create and progress a change but cannot approve one. Approval comes from a human via SSO+MFA or from a template CAB blessed once. If the machine can approve its own change, the control is theatre and the audit fails.

  5. Why re-mint the OAuth token in every pipeline job? Access tokens live ~30 minutes by default; a CAB approval that takes an hour outlives a token minted in the first job. Re-minting per job (Vault import + oauth_token.do) avoids a 401 on the poll or close step.

  6. How does a standard change clear the gate so fast? It’s created from a CAB-approved template against /change/standard/{template_sys_id}; the template supplies the type, risk, and pre-approval, so the record is born approved and the poll clears on the first attempt — no human in the deploy path.

  7. How do you make the deploy a hard gate on approval in GitHub Actions? The deploy job declares needs: await-approval, and await-approval exits non-zero on rejection or timeout. GitHub won’t start deploy until the gate job succeeds, so a rejected or unapproved change means the deploy never runs.

  8. What’s the risk of hard-coding change state numbers? Instances can customise the state model (rename/renumber states), so a hard-coded "state":"3" may not mean Closed on that instance — your close PATCH fails the transition silently and the change sticks in the wrong state. Read one record’s state.display_value and encode the instance’s real values.

  9. What is DevOps Change Velocity and when would you use it over a hand-rolled integration? It’s a ServiceNow app (formerly DevOps Change Acceleration) that auto-creates and auto-approves changes from pipeline telemetry against a declarative policy — no bespoke API in your YAML. Use it to standardise many pipelines at scale when you have the subscription; hand-roll for bespoke flows, unusual fields, or when you only own base ITSM.

  10. How do you attach test results to a change, and why does it matter? POST /api/now/attachment/file?table_name=change_request&table_sys_id={sys_id}&file_name=junit.xml with the file bytes. It matters because a change without evidence is a title an auditor can’t verify — attaching tests, plans, and scans turns an audit into a link rather than an investigation.

  11. How do you close a change honestly from a pipeline? PATCH to state 3 (Closed) with a close_code chosen by outcome — if: success()successful, if: failure()unsuccessful — so the record always reflects what actually happened, which is what keeps the change log and production from drifting.

  12. Which certs and frameworks does this map to? ITIL 4 (Change Enablement), SOC 2 / ITGC (change management and separation-of-duties controls), and the ServiceNow Certified Application Developer / CIS-ITSM and DevOps certifications. The pattern also satisfies PCI-DSS and ISO 27001 change-control requirements.

Quick check

  1. Which API enforces the change state model and approval workflow — the Change API or the Table API?
  2. You send a body with type:"normal" and risk:"moderate" to /change/standard/{tpl} and get a 400. Why?
  3. Your poll loop only checks for approved. What happens when the change is rejected?
  4. Why must the pipeline re-mint its OAuth token in each job?
  5. What close_code should the pipeline set when the deploy fails mid-apply?

Answers

  1. The Change API (sn_chg_rest) — it’s workflow-aware and refuses illegal transitions; the Table API is dumb CRUD that bypasses the process.
  2. The standard endpoint expects the template to supply type/risk — sending them (a Normal-change body) to the standard endpoint is a mismatch. Use /change for Normal, /change/standard/{tpl} for Standard (no type/risk).
  3. It spins until the timeout and then fails, wasting up to the full poll window. Branch on rejected (and duplicate) explicitly to fail fast.
  4. Access tokens expire in ~30 minutes; a CAB wait longer than that outlives a token minted in job 1, causing a 401 on the poll or close. Re-mint per job.
  5. unsuccessful — via the if: failure() step — so the change record honestly reflects the failed deploy and the log doesn’t drift.

Glossary

Next steps

ServiceNowGitHub ActionsAzure DevOpsCI/CDChange ManagementDevOps Change VelocityITSMITIL
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading