# ServiceNow Customer Service Management

> Send ServiceNow Customer Service Management cases and their customer-visible comments to Dimension Labs with the universal API.

This brings in each ServiceNow Customer Service Management (CSM) case as one conversation: the **Additional comments** on it, from the customer and from your agents. **Work notes** are internal and stay out. You run it on your side: a small script pulls from the ServiceNow Table API and posts each comment to the [universal API](/api/tracker).

## What you need

- A ServiceNow instance with Customer Service Management, and its name (the `yourinstance` in `yourinstance.service-now.com`).
- A ServiceNow admin, to create an integration user.
- An integration user that can read the Case (`sn_customerservice_case`), Journal Entry (`sys_journal_field`) and Contact (`customer_contact`) tables. The Table API returns only what this user's roles and access controls allow, so your admin picks the roles.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in ServiceNow

1. Have your admin create the integration user and give it read access to the three tables above.
2. Authenticate with Basic auth, using that user's user name and password. The Table API examples use `--user 'username':'password'` ([Table API](https://www.servicenow.com/docs/r/australia/api-reference/rest-apis/c_TableAPI.html)).
3. Check the user can read journal entries:

```bash
curl -u "$SN_USER:$SN_PASSWORD" -H "Accept: application/json" \
  "https://yourinstance.service-now.com/api/now/table/sys_journal_field?sysparm_query=element=comments&sysparm_limit=1"
```

Comments and work notes aren't columns on the case. ServiceNow keeps each entry as its own row in `sys_journal_field`: `element_id` is the sys_id of the case and `element` is the journal field, `comments` for Additional comments ([retrieving journal entries](https://www.servicenow.com/docs/r/australia/api-reference/web-services/retrieving-journal-entries.html)). The page above doesn't name the column that holds the comment text. The response lists every column of the row: find the one holding the comment (on most instances it is `value`), and set `SN_JOURNAL_TEXT_FIELD` to its name.

## 2. Pull new cases

List cases updated since your cursor, then each case's comments:

```bash
curl -u "$SN_USER:$SN_PASSWORD" -H "Accept: application/json" -G \
  "https://yourinstance.service-now.com/api/now/table/sn_customerservice_case" \
  --data-urlencode 'sysparm_query=sys_updated_on>=javascript:gs.minutesAgo(60)^ORDERBYsys_updated_on' \
  --data-urlencode 'sysparm_fields=sys_id,number,state,priority,account,contact' \
  --data-urlencode 'sysparm_exclude_reference_link=true' \
  --data-urlencode 'sysparm_limit=1000'
```

- `gs.minutesAgo(n)` returns the UTC time `n` minutes ago, so the cursor needs no time zone ([GlideSystem](https://www.servicenow.com/docs/r/australia/api-reference/server-api-reference/c_GlideSystemAPI.html)). The code turns `since` into minutes.
- Page with `sysparm_limit` and `sysparm_offset`, or follow the `rel="next"` URL in the `Link` response header. ServiceNow applies the limit before access controls, so a page can come back short: don't stop on a short page.
- Dates come back in UTC, as `yyyy-MM-dd HH:mm:ss`. Leave `sysparm_display_value` off: display values use the user's time zone and cost performance.

For each case, get its comments: `GET /api/now/table/sys_journal_field?sysparm_query=element_id={case sys_id}^element=comments^ORDERBYsys_created_on`. Asking for `element=comments` leaves out work notes.

Rate limits: ServiceNow lets admins set hourly rate limit rules for inbound REST requests, by user, role or for everyone. A request over the limit returns `429` with a `Retry-After` header in seconds ([inbound REST API rate limiting](https://www.servicenow.com/docs/r/australia/api-reference/rest-api-explorer/inbound-REST-API-rate-limiting.html)).

## 3. Map the fields

| Dimension Labs | ServiceNow | Notes |
|---|---|---|
| `text` | journal entry, column `SN_JOURNAL_TEXT_FIELD` | Only `element=comments` (Additional comments). Work notes are never requested. |
| `userId` | case `contact` | The contact's sys_id, on every comment of the case. |
| `incoming` | journal `sys_created_by` equals the contact's `user_name` | `true` for the contact's comments, `false` for everyone else's. |
| `sessionId` | case `number` | Prefix it, for example `servicenow-CS0001234`. |
| `conversationId` | case `number` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | journal `sys_created_on` | UTC without an offset. `toMillis` reads it as UTC. |
| `platformJson` | case `state`, `priority`, `account` | `account` is the customer account's sys_id: use it to join to account data. |

The code skips cases with no contact (for example consumer cases), because it can't tell the customer's comments from your agents' without one. Add the consumer's user name the same way if you handle consumer cases.

**Personal data.** Don't send the contact's name, email or user name. Comments often hold names, addresses and phone numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// servicenow.mjs. Environment: SN_INSTANCE (yourinstance), SN_USER, SN_PASSWORD,
// SN_JOURNAL_TEXT_FIELD (the journal column that holds the text, see step 1), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = `https://${process.env.SN_INSTANCE}.service-now.com/api/now/table`;
const auth = 'Basic ' + Buffer.from(`${process.env.SN_USER}:${process.env.SN_PASSWORD}`).toString('base64');
const TEXT = process.env.SN_JOURNAL_TEXT_FIELD;
const ref = (v) => (v && typeof v === 'object' ? v.value : v) || '';

// Retries ServiceNow's rate limits (429) and server errors (5xx), honoring Retry-After.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
    if (res.ok) return { body: await res.json(), link: res.headers.get('link') ?? '' };
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`ServiceNow returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Every row of a Table API query, following the rel="next" link.
async function list(table, query, fields) {
  const rows = [];
  let url = `${base}/${table}?sysparm_query=${encodeURIComponent(query)}&sysparm_fields=${fields}&sysparm_exclude_reference_link=true&sysparm_limit=1000`;
  while (url) {
    const page = await get(url);
    rows.push(...page.body.result);
    url = /<([^>]+)>;\s*rel="next"/.exec(page.link)?.[1] ?? null;
  }
  return rows;
}

// Sends every Additional comment written at or after `since` (Unix ms). Returns the new `since`.
export async function syncServiceNow({ since, historical = false }) {
  const started = Date.now();
  if (!TEXT) throw new Error('Set SN_JOURNAL_TEXT_FIELD to the sys_journal_field column that holds the comment text (step 1).');
  const minutes = Math.ceil((Date.now() - since) / 60000) + 1;
  const cases = await list('sn_customerservice_case',
    `sys_updated_on>=javascript:gs.minutesAgo(${minutes})^ORDERBYsys_updated_on`, 'sys_id,number,state,priority,account,contact');
  const contacts = new Map();
  let newest = since;
  for (const c of cases) {
    const contact = ref(c.contact);
    if (!contact) continue; // no contact: the sides can't be told apart
    if (!contacts.has(contact)) contacts.set(contact, (await list('customer_contact', `sys_id=${contact}`, 'user_name'))[0]?.user_name ?? '');
    const userName = contacts.get(contact);
    if (!userName) continue;
    const comments = await list('sys_journal_field',
      `element_id=${c.sys_id}^element=comments^ORDERBYsys_created_on`, `sys_id,sys_created_on,sys_created_by,${TEXT}`);
    for (const j of comments) {
      const ts = toMillis(j.sys_created_on);
      const text = String(j[TEXT] ?? '').trim();
      if (ts < since || !text) continue;
      await sendToDimensionLabs({
        text,
        userId: contact,
        incoming: j.sys_created_by === userName,
        sessionId: `servicenow-${c.number}`,
        conversationId: `servicenow-${c.number}`,
        dimensionlabs_timestamp: ts,
        platformJson: { case_sys_id: c.sys_id, state: c.state, priority: c.priority, account: ref(c.account) },
      }, { historical: historical || ts < Date.now() - HOUR });
      newest = Math.max(newest, ts);
    }
  }
  // A record updated mid-run can shift the paging; stopping the cursor a little before the run started means the next run reads it. Re-sends are safe.
  return Math.max(since, Math.min(newest, started - 5 * 60 * 1000));
}
```

The case query reaches back a minute past `since`, so a comment at the boundary can be sent twice. That's fine: duplicates are removed automatically.

## 5. Run it

Save the code as `servicenow.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncServiceNow as sync } from './servicenow.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every Additional comment on cases updated since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new comments to `/track`, and they are enriched automatically every day. A comment that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

Each case costs at least one extra request, more when its contact is new to the run, so check the job against any rate limit rule your admin has set. You can also [upload a file](/data/upload-a-file) with one row per comment (check it with the [file checker](/data/check-a-file)), then run a backfill.

## ServiceNow reference

- [Table API](https://www.servicenow.com/docs/r/australia/api-reference/rest-apis/c_TableAPI.html)
- [Retrieve journal entries](https://www.servicenow.com/docs/r/australia/api-reference/web-services/retrieving-journal-entries.html)
- [Journal fields](https://www.servicenow.com/docs/r/australia/platform-administration/table-administration-and-data-management/r_JournalFields.html)
- [Tables installed with Customer Service Management](https://www.servicenow.com/docs/r/australia/customer-service-management/r_TIWCustomerService.html)
- [Operators available for filters and queries](https://www.servicenow.com/docs/r/australia/platform-user-interface/r_OpAvailableFiltersQueries.html)
- [GlideSystem](https://www.servicenow.com/docs/r/australia/api-reference/server-api-reference/c_GlideSystemAPI.html)
- [Inbound REST API rate limiting](https://www.servicenow.com/docs/r/australia/api-reference/rest-api-explorer/inbound-REST-API-rate-limiting.html)

Checked against ServiceNow's documentation on 2026-10-26.
