Building a ServiceNow integration is fundamentally different from every other API integration you've built — because there is no single ServiceNow. Every customer runs their own instance at a unique subdomain, with their own OAuth endpoints, their own permission model, and their own table customisations. Guides written for ServiceNow developers working inside an instance won't help you. This one is written for developers building a product that connects to their customers' ServiceNow instances.
Quick answer: Use the Table API (/api/now/table/{tableName}) for reading and writing incidents, users, groups, and requests. Authenticate via OAuth 2.0 — but collect the customer's instance URL first, since every OAuth endpoint is instance-specific. The five tables that cover 90% of ITSM product integration use cases areincident,sys_user,sys_user_group,sc_request, andchange_request.
This guide covers per-instance OAuth setup, Table API endpoints and query syntax, webhook configuration, rate limits, and three real-world integration patterns with working code — all from the perspective of an external developer connecting to a customer's ServiceNow instance.
If your product needs to support ServiceNow alongside other ITSM tools like Jira, Zendesk, or GitHub Issues, there's a unified approach worth knowing about — covered in the Building with Knit section.
The ServiceNow API: Table API, Scripted REST, and Import Sets
ServiceNow exposes several API surfaces. The right one for your integration depends on what you're doing:
The Table API is the right choice for the vast majority of product integrations. It provides CRUD access to any ServiceNow table through a consistent URL pattern:
https://{instance}.service-now.com/api/now/table/{tableName}The Scripted REST API requires a ServiceNow developer to create custom endpoints inside the instance — you can't deploy these from outside. The Import Set API is for bulk historical data loads, not real-time integrations.
Authentication: Per-Instance OAuth and Why It's Different
ServiceNow OAuth is standard OAuth 2.0 in mechanics, but the endpoints are not standard — they're instance-specific. This is the detail that trips most developers up when building a multi-tenant integration.
For a typical API (Slack, GitHub, HubSpot), you hardcode a single OAuth endpoint:
https://slack.com/api/oauth.v2.accessFor ServiceNow, every customer has their own:
https://{customer-instance}.service-now.com/oauth_token.do
https://{customer-instance}.service-now.com/oauth_auth.doThis means your integration must:
- Collect the customer's ServiceNow instance URL before initiating OAuth
- Construct the OAuth endpoints dynamically per customer
- Store per-customer OAuth credentials (access token, refresh token, instance URL)
- Handle token refresh per customer independently
Here's what that looks like in practice:
Step 1: Collect the Instance URL
Your onboarding UI needs to ask for the instance identifier — the [company] part of https://[company].service-now.com. This is what Knit's auth screen shows users when they connect ServiceNow.
def get_servicenow_endpoints(instance: str) -> dict:
"""
Build instance-specific OAuth endpoints from the instance identifier.
instance = "mycompany" (not the full URL)
"""
base = f"https://{instance}.service-now.com"
return {
"base_url": base,
"auth_url": f"{base}/oauth_auth.do",
"token_url": f"{base}/oauth_token.do",
"api_base": f"{base}/api/now/table"
}Step 2: Register an OAuth Provider in ServiceNow
Before any OAuth flow can happen, the customer's ServiceNow admin must register your application as an OAuth provider in their instance: System OAuth > Application Registry > New > Create an OAuth API endpoint for external clients.
Required fields:
- Name: Your application name
- Client ID: Auto-generated (give this to the customer)
- Client Secret: Auto-generated (store securely)
- Redirect URL: Your callback URL
This is a one-time admin step per customer instance. Document it clearly in your onboarding instructions.
Step 3: OAuth Authorization Flow
import requests
from urllib.parse import urlencode
def get_auth_url(instance: str, client_id: str, redirect_uri: str, state: str) -> str:
"""Redirect the customer's admin to this URL to initiate OAuth consent."""
endpoints = get_servicenow_endpoints(instance)
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": state # CSRF protection — always validate on callback
}
return f"{endpoints['auth_url']}?{urlencode(params)}"
def exchange_code_for_tokens(instance: str, client_id: str, client_secret: str,
code: str, redirect_uri: str) -> dict:
"""Exchange the authorization code for access + refresh tokens."""
endpoints = get_servicenow_endpoints(instance)
response = requests.post(
endpoints["token_url"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret
}
)
response.raise_for_status()
tokens = response.json()
# Store tokens["access_token"], tokens["refresh_token"], and instance per customer
return tokensStep 4: Token Refresh
ServiceNow access tokens expire after 30 minutes by default (configurable by the admin). Build refresh logic before you hit your first expiry:
def refresh_access_token(instance: str, client_id: str, client_secret: str,
refresh_token: str) -> dict:
endpoints = get_servicenow_endpoints(instance)
response = requests.post(
endpoints["token_url"],
data={
"grant_type": "refresh_token",
"client_secret": client_secret,
"client_id": client_id,
"refresh_token": refresh_token
}
)
response.raise_for_status()
return response.json() # New access_token and refresh_tokenIf you're building a product that integrates with ServiceNow alongside other ITSM tools — Jira, Zendesk, GitHub, Linear — building and maintaining per-instance OAuth for each one is significant infrastructure overhead. Knit handles ServiceNow's instance URL collection and OAuth flow per customer, so you get a single integration layer across all your supported tools. → getknit.dev/integration/servicenow
Key Table API Endpoints and the Five Tables That Matter
The Tables
ServiceNow has hundreds of tables. For a B2B product integration, these five cover the vast majority of use cases:
All Table API requests follow the same pattern:
GET https://{instance}.service-now.com/api/now/table/{table}
Authorization: Bearer {access_token}
Accept: application/json
Content-Type: application/json
X-no-response-body: falseThe sysparm Parameters
ServiceNow's Table API uses sysparm_ prefixed query parameters for filtering, field selection, and pagination. Understanding these is essential — without them you'll either pull the entire table or struggle with pagination.
Reading Incidents
def get_incidents(instance: str, token: str,
state: str = None, assigned_to: str = None,
limit: int = 100, offset: int = 0) -> dict:
"""
Fetch incidents from a ServiceNow instance.
state codes: 1=New, 2=In Progress, 3=On Hold, 6=Resolved, 7=Closed
"""
query_parts = []
if state:
query_parts.append(f"state={state}")
if assigned_to:
query_parts.append(f"assigned_to.user_name={assigned_to}")
params = {
"sysparm_limit": limit,
"sysparm_offset": offset,
"sysparm_fields": "sys_id,number,short_description,description,state,"
"priority,assigned_to,assignment_group,opened_at,"
"resolved_at,sys_created_on,sys_updated_on",
"sysparm_exclude_reference_link": "true",
"sysparm_display_value": "false" # Raw values are easier to work with
}
if query_parts:
params["sysparm_query"] = "^".join(query_parts)
response = requests.get(
f"https://{instance}.service-now.com/api/now/table/incident",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json"
},
params=params
)
response.raise_for_status()
# Pagination: check X-Total-Count header for total record count
total = int(response.headers.get("X-Total-Count", 0))
return {
"records": response.json()["result"],
"total": total,
"has_more": (offset + limit) < total
}Creating an Incident
def create_incident(instance: str, token: str,
short_description: str, description: str,
caller_id: str = None, priority: int = 3,
assignment_group: str = None) -> dict:
"""
Creates an incident. Priority: 1=Critical, 2=High, 3=Moderate, 4=Low.
caller_id and assignment_group are sys_id values from sys_user/sys_user_group.
"""
payload = {
"short_description": short_description,
"description": description,
"priority": str(priority),
"impact": str(priority), # Often mirrors priority
"urgency": str(priority)
}
if caller_id:
payload["caller_id"] = caller_id
if assignment_group:
payload["assignment_group"] = assignment_group
response = requests.post(
f"https://{instance}.service-now.com/api/now/table/incident",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json"
},
json=payload
)
response.raise_for_status()
result = response.json()["result"]
return {
"sys_id": result["sys_id"], # Use this for future updates
"number": result["number"], # Human-readable e.g. INC0012345
"state": result["state"],
"url": f"https://{instance}.service-now.com/nav_to.do?uri=incident.do?sys_id={result['sys_id']}"
}Updating an Incident
def update_incident(instance: str, token: str,
sys_id: str, **fields) -> dict:
"""
Update any incident fields by sys_id.
Common fields: state, assigned_to, assignment_group, work_notes, close_notes
"""
response = requests.patch(
f"https://{instance}.service-now.com/api/now/table/incident/{sys_id}",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json"
},
json=fields
)
response.raise_for_status()
return response.json()["result"]Users and Groups
# Get a user by their email address (common lookup pattern)
def get_user_by_email(instance: str, token: str, email: str) -> dict | None:
response = requests.get(
f"https://{instance}.service-now.com/api/now/table/sys_user",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
params={
"sysparm_query": f"email={email}^active=true",
"sysparm_fields": "sys_id,name,email,user_name",
"sysparm_limit": 1,
"sysparm_exclude_reference_link": "true"
}
)
response.raise_for_status()
results = response.json()["result"]
return results[0] if results else None
# List all active groups
def list_groups(instance: str, token: str) -> list:
response = requests.get(
f"https://{instance}.service-now.com/api/now/table/sys_user_group",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
params={
"sysparm_query": "active=true",
"sysparm_fields": "sys_id,name,description,manager",
"sysparm_limit": 1000,
"sysparm_exclude_reference_link": "true"
}
)
response.raise_for_status()
return response.json()["result"]Webhooks: Business Rules and Outbound REST Messages
ServiceNow does not have native outbound webhooks that you configure from outside the instance. Real-time event notifications require a ServiceNow admin on the customer side to set up two things: a Business Rule (which triggers on record events) and an Outbound REST Message (which sends the payload to your server).
This is a key difference from APIs like GitHub or Slack where you register a webhook URL programmatically. For ServiceNow, you need to provide your customers' IT teams with setup instructions.
What the customer's admin configures:
Business Rule (System Definition > Business Rules):
- Table:
incident - When to run:
afterinsert/update - Condition: (whatever triggers the notification — e.g., state changes)
- Script:
// ServiceNow Business Rule script
var message = new sn_ws.RESTMessageV2('Your Integration', 'POST incident');
message.setStringParameterNoEscape('sys_id', current.sys_id);
message.setStringParameterNoEscape('number', current.number);
message.setStringParameterNoEscape('state', current.state);
message.setStringParameterNoEscape('updated_at', current.sys_updated_on);
var response = message.execute();Outbound REST Message (System Web Services > Outbound > REST Message):
- Endpoint: your server's webhook URL
- HTTP Method: POST
- Authentication: Basic or OAuth (your server's credentials)
On your server, receive and process the payload:
from flask import Flask, request, abort
import hmac, hashlib
app = Flask(__name__)
@app.route("/webhook/servicenow", methods=["POST"])
def handle_servicenow_event():
# ServiceNow doesn't send a standard signature header —
# secure your endpoint via IP allowlisting or a shared secret
# passed as a query param or custom header agreed with the admin
payload = request.json
sys_id = payload.get("sys_id")
state = payload.get("state")
# State codes: 1=New, 2=In Progress, 3=On Hold, 6=Resolved, 7=Closed
if state in ("6", "7"):
close_linked_item_in_your_product(sys_id)
return "", 200Because webhook setup requires admin access on the customer's instance, build your integration to work without webhooks first (polling) and offer webhook setup as an enhancement for customers whose admins can configure it.
Rate Limits
ServiceNow rate limits are instance-configured, not globally fixed — your customer's IT admin controls them. This creates a situation you won't face with other APIs: two customers on the same plan can have different rate limits.
Unlike GitHub or Slack, ServiceNow does not return rate limit headers (X-RateLimit-Remaining etc.) on every response. You'll receive a 429 Too Many Requests when you hit the limit — build retry logic with exponential backoff:
import time
def servicenow_request(url: str, token: str, max_retries: int = 3, **kwargs) -> requests.Response:
for attempt in range(max_retries):
response = requests.get(url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json"
}, **kwargs)
if response.status_code == 429:
wait = 2 ** attempt * 10 # 10s, 20s, 40s
time.sleep(wait)
continue
if response.status_code == 401:
# Token likely expired — trigger refresh and retry once
raise TokenExpiredError("Access token expired")
response.raise_for_status()
return response
raise Exception(f"Max retries exceeded for {url}")For sustained high-volume integrations, use a dedicated integration user account in ServiceNow rather than a human user's account — this ensures your rate limit isn't shared with the user's other API activity.
3 Common ServiceNow Integration Patterns
Pattern 1: Sync Incidents into Your Product
Pull all open incidents and keep them in sync with periodic polling:
def full_incident_sync(instance: str, token: str) -> list:
"""
Full sync of all open and in-progress incidents.
Run on initial connection; switch to delta sync (updatedAfter) for ongoing.
"""
all_incidents = []
offset = 0
limit = 100
while True:
page = get_incidents(
instance=instance,
token=token,
limit=limit,
offset=offset
)
all_incidents.extend(page["records"])
if not page["has_more"]:
break
offset += limit
# Normalise ServiceNow state codes to your product's status model
status_map = {
"1": "open", "2": "in_progress", "3": "on_hold",
"6": "resolved", "7": "closed"
}
return [
{
"external_id": i["sys_id"],
"reference": i["number"],
"title": i["short_description"],
"status": status_map.get(str(i["state"]), "unknown"),
"priority": i["priority"],
"assignee_id": i.get("assigned_to"),
"created_at": i["sys_created_on"],
"updated_at": i["sys_updated_on"]
}
for i in all_incidents
]Pattern 2: Create an Incident from Your Product
The common "escalate to IT" pattern — a user triggers an action in your product and it creates a ServiceNow incident:
Raw ServiceNow approach — you need to resolve the user's sys_id first, look up the right assignment group sys_id, then create the incident:
# Step 1: resolve caller sys_id from user's email
caller = get_user_by_email(instance, token, user_email)
caller_sys_id = caller["sys_id"] if caller else None
# Step 2: look up assignment group sys_id
groups = list_groups(instance, token)
group = next((g for g in groups if g["name"] == "IT Help Desk"), None)
group_sys_id = group["sys_id"] if group else None
# Step 3: create the incident
incident = create_incident(
instance=instance,
token=token,
short_description=f"Alert from {your_product}: {alert_title}",
description=alert_details,
caller_id=caller_sys_id,
assignment_group=group_sys_id,
priority=2 # High
)
# Store incident["sys_id"] in your DB for future status syncWith Knit — skip the sys_id resolution steps. Knit's normalised endpoints return consistent IDs you can use directly:
# Get incidents already filtered and paginated
incidents = requests.get(
"https://api.getknit.dev/v1.0/ticketing/tickets.list",
headers={
"Authorization": f"Bearer {knit_token}",
"X-Knit-Integration-Id": integration_id
},
params={"status": "OPEN", "assignedToId": user_id}
)
# Update an incident's status
requests.post(
"https://api.getknit.dev/v1.0/ticketing/ticket.update",
headers={
"Authorization": f"Bearer {knit_token}",
"X-Knit-Integration-Id": integration_id
},
json={"ticketId": ticket_id, "status": "IN_PROGRESS", "assignedToId": agent_id}
)Pattern 3: User and Group Sync for Access Control
Many products need to know which ServiceNow users and groups a customer has, to map them to your product's access model:
def sync_users_and_groups(instance: str, token: str) -> dict:
"""
Sync all active users and groups from ServiceNow.
Used to populate assignee pickers and map access levels.
"""
# Fetch users — paginate if the instance has many
users_response = requests.get(
f"https://{instance}.service-now.com/api/now/table/sys_user",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
params={
"sysparm_query": "active=true",
"sysparm_fields": "sys_id,name,email,user_name,department",
"sysparm_limit": 1000,
"sysparm_exclude_reference_link": "true"
}
)
users = users_response.json()["result"]
# Fetch groups
groups = list_groups(instance, token)
return {
"users": [
{"id": u["sys_id"], "name": u["name"],
"email": u["email"], "username": u["user_name"]}
for u in users
],
"groups": [
{"id": g["sys_id"], "name": g["name"]}
for g in groups
]
}Building ServiceNow Integrations with Knit
The two hardest parts of a ServiceNow product integration are both auth-related: collecting the instance URL from each customer, constructing per-instance OAuth endpoints, and managing token refresh independently per customer installation. These are real engineering problems that have nothing to do with the value you're delivering to users.
Knit handles ServiceNow authentication — including instance URL collection and per-customer OAuth — so your integration starts from a normalised API call rather than an auth infrastructure build. The same Knit headers work across all your ticketing integrations:
Authorization: Bearer {your-knit-token}
X-Knit-Integration-Id: {customer-integration-id}This is especially valuable if your product also supports Jira, Zendesk, GitHub Issues, Linear, or Asana — Knit's same API surface covers all of them, so you write the integration logic once.
The Knit APIs available for ServiceNow:
Example: list open high-priority incidents via Knit
/
import requests
def get_open_high_priority_incidents(knit_token: str, integration_id: str) -> list:
"""
No instance URL handling. No token refresh. No sysparm syntax.
Works the same way for ServiceNow, Jira, Zendesk, and every other Knit-supported tool.
"""
all_tickets = []
cursor = None
while True:
params = {"status": "OPEN"}
if cursor:
params["cursor"] = cursor
response = requests.get(
"https://api.getknit.dev/v1.0/ticketing/tickets.list",
headers={
"Authorization": f"Bearer {knit_token}",
"X-Knit-Integration-Id": integration_id
},
params=params
)
response.raise_for_status()
data = response.json()["data"]
all_tickets.extend(data["tickets"])
cursor = data["pagination"].get("next")
if not cursor:
break
return all_tickets
→ See the full ServiceNow integration on Knit: getknit.dev/integration/servicenow
→ Knit's ticketing API docs: developers.getknit.dev
What to Build First
- Build your instance URL collection UI — a simple input field asking for the ServiceNow instance identifier. This unlocks everything else. Document clearly what format you expect (
mycompany, nothttps://mycompany.service-now.com). - Write your dynamic OAuth endpoint constructor — a utility function that builds token and auth URLs from the instance identifier. Every other piece of your auth layer depends on this.
- Prepare your onboarding documentation for customer admins — ServiceNow OAuth requires the customer's IT admin to register your application. Write a clear step-by-step guide before any customer goes through onboarding.
- Build token storage with per-customer isolation — access token, refresh token, instance URL, and expiry time per customer. Implement token refresh before your first expiry, not after.
- Implement incident list and create endpoints — these cover the primary use case for 80%+ of ServiceNow integrations. Use
sysparm_fieldsfrom the start to avoid pulling data you don't need. - Build user and group sync — fetch
sys_userandsys_user_groupon integration setup and cache the results. These change infrequently and are needed to populate assignee pickers and resolve group names. - Add delta sync for incident updates — poll
incidentwithsysparm_query=sys_updated_on>javascript:gs.dateGenerate('YYYY-MM-DD','HH:mm:ss')to fetch only records changed since your last sync rather than re-pulling everything. - Document the webhook setup process — provide your customers' admins with a Business Rule + Outbound REST Message template they can deploy, enabling real-time sync without polling.
Summary
Frequently Asked Questions
What is the ServiceNow Table API?
The ServiceNow Table API is the primary REST interface for reading and writing records across any ServiceNow table. It exposes endpoints at https://{instance}.service-now.com/api/now/table/{tableName} and supports GET, POST, PUT, PATCH, and DELETE operations. For product integrations, the most relevant tables are incident, sys_user, sys_user_group, sc_request, and change_request. The Table API supports powerful query filtering via the sysparm_query parameter.
How do I authenticate with the ServiceNow REST API?
ServiceNow supports OAuth 2.0 (recommended for production) and Basic Auth. For OAuth, the token endpoint is https://{instance}.service-now.com/oauth_token.do and the authorization endpoint is https://{instance}.service-now.com/oauth_auth.do — both are instance-specific, so you must collect the customer's instance URL before initiating the OAuth flow. Tokens expire after 30 minutes by default; use the refresh token to obtain new ones without user interaction.
What is sysparm_query in ServiceNow?
sysparm_query is the ServiceNow Table API's parameter for filtering records. It uses ServiceNow's encoded query syntax: field operators joined with ^ (AND) or ^OR (OR). Common operators include =, !=, IN, STARTSWITH, CONTAINS. Example: state=1^assigned_toISNOTEMPTY^opened_at>=javascript:gs.beginningOfLast30Days(). Build queries in the ServiceNow Filter Builder UI first, then copy the encoded query string to use in your API calls.
What are the ServiceNow API rate limits?
ServiceNow API rate limits are configured per instance by the customer's admin, not fixed globally. The default is typically 5,000 API requests per hour per user account, but enterprise instances can have this set differently. ServiceNow does not return standard rate limit headers on every response — watch for 429 Too Many Requests and implement exponential backoff. The API defaults to a maximum of 10,000 records per single Table API query (controlled by the glide.db.max_view_records system property — most instances leave this at the default).
How do ServiceNow webhooks work?
ServiceNow does not have native outbound webhooks that you register from outside the instance. Real-time event notifications are built using Business Rules (server-side scripts that fire on table record events) combined with Outbound REST Messages. This requires a ServiceNow admin on the customer's side to configure. For integrations where webhook setup isn't feasible, use delta polling: query the incident table with a sys_updated_on> filter on a schedule.
What is the difference between the ServiceNow Table API and Import Set API?
The Table API directly reads and writes records with immediate effect — the right choice for most product integrations. The Import Set API stages data in a temporary table first, then a transform map processes it into the target table. Use Import Sets only for bulk historical data migration. For real-time integrations involving incidents, users, and groups, always use the Table API.
Which ServiceNow tables should I use for an ITSM integration?
Focus on five tables: incident for IT incidents, sys_user for user records, sys_user_group for team assignments, sc_request for service catalog requests, and change_request for change management. The incident table's state field uses numeric codes — 1=New, 2=In Progress, 3=On Hold, 6=Resolved, 7=Closed — always map these explicitly in your code rather than relying on display values.
Is there a simpler way to integrate with ServiceNow without building per-instance OAuth for each customer?
Yes. Knit provides a unified ticketing API that handles ServiceNow authentication — including collecting the instance URL and managing the per-instance OAuth flow per customer. Instead of building dynamic OAuth endpoint logic, token refresh, and per-customer credential storage, your customers connect their ServiceNow instance once through Knit's auth layer. You then call Knit's normalised endpoints for incidents, accounts, contacts, users, and groups — the same interface that works across Jira, GitHub, Zendesk, and more. → getknit.dev/integration/servicenow





