Skip to main content
Feature entitlement: eventStreams · Plan: 👑 Ultra Device Plan

Connect Event Stream Distributor to ServiceNow

This guide walks through building the ServiceNow endpoint that receives signageOS events, verifying it, and pointing an exporter at it, so that an incident opens on its own when an alert rule fires on a device. For the concepts behind ESD, see Introduction to Event Stream Distributor.

ServiceNow has no built-in webhook receiver, so the endpoint is a Scripted REST API resource with a script that checks the delivery signature and inserts the incident.

What the endpoint has to do

Four rules govern any endpoint that receives deliveries, whatever you build it on. The script in Step 3 implements all four; if you adapt it, or point an exporter at a different system, keep them.

  • Verify the signature over the raw request body. Parsing the JSON and re-serializing it is not guaranteed to reproduce the same byte string, and the signature covers exactly what was sent.
  • Deduplicate on the event id. It is stable across redeliveries, so the same event arriving twice must not open a second ticket.
  • Answer 2xx. Any 4xx other than 408 and 429 marks the delivery permanently failed and the event is never redelivered. 5xx, 408, 429 and timeouts are retried.
  • Accept a timestamp up to two hours old. X-SOS-Timestamp is the dispatch time, not the delivery time, and retries are spread over roughly an hour.

Before you start

  • A signageOS organization with the eventStreams entitlement and an Organization Auth Token.

  • A ServiceNow instance where you can create a Scripted REST API and system properties.

  • An instance that allows the endpoint to accept unauthenticated requests. signageOS authenticates each delivery by signing the request body, not with credentials, so the resource must have Requires authentication unchecked. If your instance policy forbids public Scripted REST resources, stop here — the exporter cannot present credentials. Do not work around it by putting a username and password in the endpoint URL: the URL is readable back from the exporter configuration, so the password would not stay secret.

  • A signing secret of at least 32 characters, which both sides will share:

    openssl rand -base64 48

    Store it now. The secret is write-only in signageOS — no endpoint returns it after you create the exporter.

Step 1 — Create the endpoint in ServiceNow

In ServiceNow, create a Scripted REST API and add a resource to it. For the mechanics, follow ServiceNow's own documentation — the form layout and the available fields change between releases. Configure the resource as follows:

FieldValue
HTTP methodPOST
Requires authenticationunchecked
Relative pathyour choice, for example /event

Requires authentication must be unchecked. signageOS authenticates each delivery with a signature over the request body, not with a credential, so no Authorization header is sent. The signature check in Step 3 is what secures the endpoint. If your instance prohibits a public Scripted REST resource, this integration is not available to you.

Then read the Base API path from the saved Scripted REST API record and append the resource's relative path. That full URL is your endpoint:

https://<instance>.service-now.com/api/<namespace>/<api-id>/event

The namespace is generated per instance, so read it from the form rather than assuming it.

Step 2 — Store the secret and the URL

The script needs the signing secret, and it needs the endpoint URL because the signature covers it. Create two system properties (sys_properties):

NameValue
device_events.signing_secretthe secret from Before you start
device_events.webhook_urlthe full endpoint URL from Step 1

The names are your choice, as long as they match the script. Avoid an x_-prefixed name: ServiceNow reserves that pattern for scoped applications.

Step 3 — Add the script

Paste this into the resource's Script field. It verifies the signature, ignores repeat deliveries of an event it has already seen, and inserts an incident.

(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {
var SIGNATURE_PREFIX = 'v1=';
// X-SOS-Timestamp is when the event was dispatched, not when it was delivered, and a retried
// delivery repeats the original value. Retries are spread over roughly an hour, so a tighter
// window would reject valid retries.
var MAX_SKEW_SECONDS = 7200;

var secret = gs.getProperty('device_events.signing_secret');
var signedUrl = gs.getProperty('device_events.webhook_url');

// The signature covers the exact bytes received. Never use request.body.data instead — parsing
// and re-serializing the JSON is not guaranteed to reproduce the same string. dataString can
// only be read once.
var rawBody = request.body.dataString;

var providedSignature = request.getHeader('x-sos-signature');
var timestamp = request.getHeader('x-sos-timestamp');

if (!providedSignature || !timestamp || providedSignature.indexOf(SIGNATURE_PREFIX) !== 0) {
response.setStatus(401);
return { error: 'missing_signature' };
}

// generateMac requires a Base64-encoded key. The secret is a raw string, so it must be encoded
// here; passing it raw produces a wrong MAC and every delivery fails with 401.
var encodedKey = GlideStringUtil.base64Encode(secret);
var signedContent = 'POST' + signedUrl + timestamp + rawBody;
var expected = new GlideCertificateEncryption().generateMac(encodedKey, 'HmacSHA256', signedContent);

if (!constantTimeEquals(expected, providedSignature.substring(SIGNATURE_PREFIX.length))) {
gs.warn('[device events] signature mismatch');
response.setStatus(401);
return { error: 'invalid_signature' };
}

var skew = Math.abs(Math.floor(new Date().getTime() / 1000) - parseInt(timestamp, 10));
if (skew > MAX_SKEW_SECONDS) {
response.setStatus(401);
return { error: 'stale_timestamp' };
}

var event = JSON.parse(rawBody);

// The same event can be delivered more than once. Its id is stable, so use it to recognise a
// repeat and avoid opening a second incident.
if (findExistingIncident(event.id)) {
response.setStatus(200);
return { status: 'duplicate_ignored', eventId: event.id };
}

var incident = new GlideRecord('incident');
incident.initialize();
incident.setValue('short_description', buildShortDescription(event));
incident.setValue('description', JSON.stringify(event.payload, null, 2));
incident.setValue('category', 'hardware');
incident.setValue('correlation_id', event.id);
incident.setValue('correlation_display', 'signageOS:' + event.type);
// ServiceNow derives Priority from impact and urgency; setting priority directly has no effect.
incident.setValue('impact', 2);
incident.setValue('urgency', 1);
var sysId = incident.insert();

response.setStatus(201);
return { status: 'created', incidentSysId: String(sysId), eventId: event.id };

function findExistingIncident(eventId) {
var gr = new GlideRecord('incident');
gr.addQuery('correlation_id', eventId);
gr.setLimit(1);
gr.query();
return gr.next();
}

function buildShortDescription(event) {
var device = event.payload && event.payload.deviceIdentityHash;
return 'signageOS ' + event.type + (device ? ' — device ' + device : '');
}

// Rhino has no crypto.timingSafeEqual; compare in fixed time over the full length.
function constantTimeEquals(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) {
return false;
}
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
})(request, response);

The script above is written for the global scope. In a scoped application, replace GlideCertificateEncryption with CertificateEncryption and GlideStringUtil.base64Encode with gs.base64Encode.

The incident fields are an example. Map the event to whatever table and fields your process uses.

Step 4 — Create the exporter in signageOS

Alert.AlertDeviceAssigned fires when an alert rule matches a device, which makes it the event to start with: the thresholds your operators configured have already been applied. Create the exporter disabled, so nothing is delivered until you have tested the endpoint.

curl -X POST \
https://api.signageos.io/v1/event-stream/exporter \
-H 'Content-Type: application/json' \
-H 'x-auth: 12a15XXX28612d:2e220XXX77745' \
-d '{
"organizationUid": "117b6d8XXXX18ed4c",
"name": "ServiceNow incidents",
"enabled": false,
"subscribedEventTypes": ["Alert.AlertDeviceAssigned"],
"config": {
"configType": "webhook",
"url": "https://<instance>.service-now.com/api/<namespace>/<api-id>/event",
"auth": {
"authType": "httpSignedBody",
"signing": { "algorithm": "HS256", "secret": "<your secret>" }
}
}
}'
Plan for the volume

An exporter covers the whole organization, so it delivers an event for every device an alert rule matches — not just the ones you are watching. On a large fleet that is a steady stream, and the script above opens one incident per event. Before you enable it on a production fleet, decide how you want that grouped: narrow the subscription, filter by alertRuleUid in the script, or attach repeat events to an existing open incident instead of creating a new one.

config.url and the device_events.webhook_url property must be identical strings, including any trailing slash or query string. A mismatch makes every delivery fail the signature check with no other symptom.

Save the uid from the Location response header, then enable the exporter:

curl -X PUT \
https://api.signageos.io/v1/event-stream/exporter/<uid> \
-H 'Content-Type: application/json' \
-H 'x-auth: 12a15XXX28612d:2e220XXX77745' \
-d '{ "enabled": true }'

Other events you can subscribe to for fault handling:

Event typeEmitted when
Alert.AlertDeviceUnassignedThe alert no longer applies to that device — use it to resolve the incident
Alert.AlertArchivedThe alert was archived
Device.ProvisioningRecipeStatusUpdatedA provisioning recipe changed status
Device.DeviceConnectionDeletedA device connection closed. Raw state change with no thresholds applied, so a flapping connection emits one event per transition

The complete list is in Introduction to Event Stream Distributor.

What the incident will contain

The delivered payload carries identifiers, not prose. Alert.AlertDeviceAssigned gives you alertUid, alertRuleUid and deviceIdentityHash — enough to open the incident, correlate repeat deliveries and resolve it later, but not enough to state what went wrong. That is why the script above writes a short description built from the event type and the device identity, for example signageOS Alert.AlertDeviceAssigned — device a1b2c3d4e5f6a7b8, and puts the raw payload in the description field.

To show readable text instead, resolve the identifiers over the REST API from ServiceNow and write the result onto the record:

CallReturns
GET /v1/alert/<alertUid>the alert record
GET /v1/alert-rule/<alertRuleUid>the rule that raised it

See the REST API reference for the fields each one returns. Note that this is a second, outbound integration: ServiceNow needs its own signageOS auth token, stored the same way as the signing secret in Step 2.

deviceIdentityHash is a stable public identifier for the device and is safe to key records on. It is not the device DUID, and no endpoint resolves it to a device name.

Step 5 — Verify it works

  1. Trigger a subscribed event — for example, let an alert rule match a test device.

  2. In ServiceNow, open the incident list and look for a record whose Correlation ID is the signageOS event id.

  3. Check delivery health from the signageOS side:

    curl https://api.signageos.io/v1/event-stream/exporter/<uid> \
    -H 'x-auth: 12a15XXX28612d:2e220XXX77745'

    observability.exportedMessages should increase and observability.recentFailures stay empty.

Test before you enable

A 4xx response other than 408 and 429 marks a delivery permanently failed — it is not retried. A script that answers 401 therefore loses those events for good. 5xx, 408, 429 and timeouts are retried, spread over roughly an hour.

Troubleshooting

SymptomCauseFix
401 with {"error":{"message":"User is not authenticated"}} and a WWW-Authenticate headerThe resource path does not exist. ServiceNow answers an unknown path under /api/ with 401, not 404, so a typo looks like an authentication problemCompare the resource path against the URL you are calling. Opening a working endpoint in a browser returns Method not Supported, never 401
invalid_signature on every deliveryThe secret was passed to generateMac without Base64-encoding itUse GlideStringUtil.base64Encode(secret), or gs.base64Encode(secret) in a scoped application
invalid_signature on every deliveryconfig.url differs from the stored device_events.webhook_urlCompare the two strings character by character
GlideStringUtil is not definedThe script runs in a scoped applicationUse gs.base64Encode and CertificateEncryption
Body reads as emptyrequest.body was already consumedRead dataString once into a variable
stale_timestamp on retried eventsThe replay window is shorter than the retry scheduleX-SOS-Timestamp is the dispatch time, not the delivery time. Allow at least two hours
Incident opens twice for one eventDeduplication is not keyed on the event idQuery correlation_id for event.id before inserting
Incidents stop arriving, no failures recordedThe exporter is disabled, or the event type is not subscribedCheck enabled and subscribedEventTypes on the exporter
Incident shows 2 - High when you set urgency 1ServiceNow derives Priority from impact and urgencySet impact to 1 as well
Incident Caller is guestA public resource executes as the Guest userSet caller_id explicitly in the script

Next steps