eventStreams · Plan: 👑 Ultra Device PlanConnect 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. Any4xxother than408and429marks the delivery permanently failed and the event is never redelivered.5xx,408,429and timeouts are retried. - Accept a timestamp up to two hours old.
X-SOS-Timestampis the dispatch time, not the delivery time, and retries are spread over roughly an hour.
Before you start
-
A signageOS organization with the
eventStreamsentitlement 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 48Store 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:
| Field | Value |
|---|---|
| HTTP method | POST |
| Requires authentication | unchecked |
| Relative path | your 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):
| Name | Value |
|---|---|
device_events.signing_secret | the secret from Before you start |
device_events.webhook_url | the 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>" }
}
}
}'
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 type | Emitted when |
|---|---|
Alert.AlertDeviceUnassigned | The alert no longer applies to that device — use it to resolve the incident |
Alert.AlertArchived | The alert was archived |
Device.ProvisioningRecipeStatusUpdated | A provisioning recipe changed status |
Device.DeviceConnectionDeleted | A 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:
| Call | Returns |
|---|---|
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
-
Trigger a subscribed event — for example, let an alert rule match a test device.
-
In ServiceNow, open the incident list and look for a record whose Correlation ID is the signageOS event id.
-
Check delivery health from the signageOS side:
curl https://api.signageos.io/v1/event-stream/exporter/<uid> \
-H 'x-auth: 12a15XXX28612d:2e220XXX77745'observability.exportedMessagesshould increase andobservability.recentFailuresstay empty.
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
| Symptom | Cause | Fix |
|---|---|---|
401 with {"error":{"message":"User is not authenticated"}} and a WWW-Authenticate header | The resource path does not exist. ServiceNow answers an unknown path under /api/ with 401, not 404, so a typo looks like an authentication problem | Compare 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 delivery | The secret was passed to generateMac without Base64-encoding it | Use GlideStringUtil.base64Encode(secret), or gs.base64Encode(secret) in a scoped application |
invalid_signature on every delivery | config.url differs from the stored device_events.webhook_url | Compare the two strings character by character |
GlideStringUtil is not defined | The script runs in a scoped application | Use gs.base64Encode and CertificateEncryption |
| Body reads as empty | request.body was already consumed | Read dataString once into a variable |
stale_timestamp on retried events | The replay window is shorter than the retry schedule | X-SOS-Timestamp is the dispatch time, not the delivery time. Allow at least two hours |
| Incident opens twice for one event | Deduplication is not keyed on the event id | Query correlation_id for event.id before inserting |
| Incidents stop arriving, no failures recorded | The exporter is disabled, or the event type is not subscribed | Check enabled and subscribedEventTypes on the exporter |
Incident shows 2 - High when you set urgency 1 | ServiceNow derives Priority from impact and urgency | Set impact to 1 as well |
Incident Caller is guest | A public resource executes as the Guest user | Set caller_id explicitly in the script |
Next steps
- Event Stream reference — delivery format and the full event catalogue
- Introduction to Event Stream Distributor
- Set up an exporter in Box