feat(trigger): add ServiceNow webhook triggers (#4077)

* feat(trigger): add ServiceNow webhook triggers

* fix(trigger): add webhook secret field and remove non-TSDoc comment

Add webhookSecret field to ServiceNow triggers (matching Salesforce pattern)
so users are prompted to protect the webhook endpoint. Update setup
instructions to include Authorization header in the Business Rule example.
Remove non-TSDoc inline comment in the block config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(trigger): add ServiceNow provider handler with event matching

Add dedicated ServiceNow webhook provider handler with:
- verifyAuth: validates webhookSecret via Bearer token or X-Sim-Webhook-Secret
- matchEvent: filters events by trigger type and table name using
  isServiceNowEventMatch utility (matching Salesforce/GitHub pattern)

The event matcher handles incident created/updated and change request
created/updated triggers with table name enforcement and event type
normalization. The generic webhook trigger passes through all events
but still respects the optional table name filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-04-09 13:59:07 -07:00
committed by Waleed Latif
parent 7391cd1a49
commit 9f032d98a8
12 changed files with 589 additions and 2 deletions

View File

@@ -10796,8 +10796,34 @@
}
],
"operationCount": 4,
"triggers": [],
"triggerCount": 0,
"triggers": [
{
"id": "servicenow_incident_created",
"name": "ServiceNow Incident Created",
"description": "Trigger workflow when a new incident is created in ServiceNow"
},
{
"id": "servicenow_incident_updated",
"name": "ServiceNow Incident Updated",
"description": "Trigger workflow when an incident is updated in ServiceNow"
},
{
"id": "servicenow_change_request_created",
"name": "ServiceNow Change Request Created",
"description": "Trigger workflow when a new change request is created in ServiceNow"
},
{
"id": "servicenow_change_request_updated",
"name": "ServiceNow Change Request Updated",
"description": "Trigger workflow when a change request is updated in ServiceNow"
},
{
"id": "servicenow_webhook",
"name": "ServiceNow Webhook (All Events)",
"description": "Trigger workflow on any ServiceNow webhook event"
}
],
"triggerCount": 5,
"authType": "none",
"category": "tools",
"integrationType": "customer-support",

View File

@@ -2,6 +2,7 @@ import { ServiceNowIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
import type { ServiceNowResponse } from '@/tools/servicenow/types'
import { getTrigger } from '@/triggers'
export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
type: 'servicenow',
@@ -215,6 +216,11 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
condition: { field: 'operation', value: 'servicenow_delete_record' },
required: true,
},
...getTrigger('servicenow_incident_created').subBlocks,
...getTrigger('servicenow_incident_updated').subBlocks,
...getTrigger('servicenow_change_request_created').subBlocks,
...getTrigger('servicenow_change_request_updated').subBlocks,
...getTrigger('servicenow_webhook').subBlocks,
],
tools: {
access: [
@@ -262,4 +268,14 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
success: { type: 'boolean', description: 'Operation success status' },
metadata: { type: 'json', description: 'Operation metadata' },
},
triggers: {
enabled: true,
available: [
'servicenow_incident_created',
'servicenow_incident_updated',
'servicenow_change_request_created',
'servicenow_change_request_updated',
'servicenow_webhook',
],
},
}

View File

@@ -28,6 +28,7 @@ import { outlookHandler } from '@/lib/webhooks/providers/outlook'
import { resendHandler } from '@/lib/webhooks/providers/resend'
import { rssHandler } from '@/lib/webhooks/providers/rss'
import { salesforceHandler } from '@/lib/webhooks/providers/salesforce'
import { servicenowHandler } from '@/lib/webhooks/providers/servicenow'
import { slackHandler } from '@/lib/webhooks/providers/slack'
import { stripeHandler } from '@/lib/webhooks/providers/stripe'
import { telegramHandler } from '@/lib/webhooks/providers/telegram'
@@ -72,6 +73,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
outlook: outlookHandler,
rss: rssHandler,
salesforce: salesforceHandler,
servicenow: servicenowHandler,
slack: slackHandler,
stripe: stripeHandler,
telegram: telegramHandler,

View File

@@ -0,0 +1,57 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:ServiceNow')
function asRecord(body: unknown): Record<string, unknown> {
return body && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: {}
}
export const servicenowHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret?.trim()) {
logger.warn(`[${requestId}] ServiceNow webhook missing webhookSecret — rejecting`)
return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 })
}
if (
!verifyTokenAuth(request, secret.trim(), 'x-sim-webhook-secret') &&
!verifyTokenAuth(request, secret.trim())
) {
logger.warn(`[${requestId}] ServiceNow webhook secret verification failed`)
return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 })
}
return null
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) {
return true
}
const { isServiceNowEventMatch } = await import('@/triggers/servicenow/utils')
const configuredTableName = providerConfig.tableName as string | undefined
const obj = asRecord(body)
if (!isServiceNowEventMatch(triggerId, obj, configuredTableName)) {
logger.debug(
`[${requestId}] ServiceNow event mismatch for trigger ${triggerId}. Skipping execution.`,
{ webhookId: webhook.id, workflowId: workflow.id, triggerId }
)
return false
}
return true
},
}

View File

@@ -235,6 +235,13 @@ import {
salesforceRecordUpdatedTrigger,
salesforceWebhookTrigger,
} from '@/triggers/salesforce'
import {
servicenowChangeRequestCreatedTrigger,
servicenowChangeRequestUpdatedTrigger,
servicenowIncidentCreatedTrigger,
servicenowIncidentUpdatedTrigger,
servicenowWebhookTrigger,
} from '@/triggers/servicenow'
import { slackWebhookTrigger } from '@/triggers/slack'
import { stripeWebhookTrigger } from '@/triggers/stripe'
import { telegramWebhookTrigger } from '@/triggers/telegram'
@@ -437,6 +444,11 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
salesforce_opportunity_stage_changed: salesforceOpportunityStageChangedTrigger,
salesforce_case_status_changed: salesforceCaseStatusChangedTrigger,
salesforce_webhook: salesforceWebhookTrigger,
servicenow_incident_created: servicenowIncidentCreatedTrigger,
servicenow_incident_updated: servicenowIncidentUpdatedTrigger,
servicenow_change_request_created: servicenowChangeRequestCreatedTrigger,
servicenow_change_request_updated: servicenowChangeRequestUpdatedTrigger,
servicenow_webhook: servicenowWebhookTrigger,
stripe_webhook: stripeWebhookTrigger,
telegram_webhook: telegramWebhookTrigger,
typeform_webhook: typeformWebhookTrigger,

View File

@@ -0,0 +1,37 @@
import { ServiceNowIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildChangeRequestOutputs,
buildServiceNowExtraFields,
servicenowSetupInstructions,
servicenowTriggerOptions,
} from '@/triggers/servicenow/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* ServiceNow Change Request Created Trigger
*/
export const servicenowChangeRequestCreatedTrigger: TriggerConfig = {
id: 'servicenow_change_request_created',
name: 'ServiceNow Change Request Created',
provider: 'servicenow',
description: 'Trigger workflow when a new change request is created in ServiceNow',
version: '1.0.0',
icon: ServiceNowIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'servicenow_change_request_created',
triggerOptions: servicenowTriggerOptions,
setupInstructions: servicenowSetupInstructions('Insert (record creation)'),
extraFields: buildServiceNowExtraFields('servicenow_change_request_created'),
}),
outputs: buildChangeRequestOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,37 @@
import { ServiceNowIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildChangeRequestOutputs,
buildServiceNowExtraFields,
servicenowSetupInstructions,
servicenowTriggerOptions,
} from '@/triggers/servicenow/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* ServiceNow Change Request Updated Trigger
*/
export const servicenowChangeRequestUpdatedTrigger: TriggerConfig = {
id: 'servicenow_change_request_updated',
name: 'ServiceNow Change Request Updated',
provider: 'servicenow',
description: 'Trigger workflow when a change request is updated in ServiceNow',
version: '1.0.0',
icon: ServiceNowIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'servicenow_change_request_updated',
triggerOptions: servicenowTriggerOptions,
setupInstructions: servicenowSetupInstructions('Update (record modification)'),
extraFields: buildServiceNowExtraFields('servicenow_change_request_updated'),
}),
outputs: buildChangeRequestOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,40 @@
import { ServiceNowIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildIncidentOutputs,
buildServiceNowExtraFields,
servicenowSetupInstructions,
servicenowTriggerOptions,
} from '@/triggers/servicenow/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* ServiceNow Incident Created Trigger
*
* Primary trigger — includes the dropdown for selecting trigger type.
*/
export const servicenowIncidentCreatedTrigger: TriggerConfig = {
id: 'servicenow_incident_created',
name: 'ServiceNow Incident Created',
provider: 'servicenow',
description: 'Trigger workflow when a new incident is created in ServiceNow',
version: '1.0.0',
icon: ServiceNowIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'servicenow_incident_created',
triggerOptions: servicenowTriggerOptions,
includeDropdown: true,
setupInstructions: servicenowSetupInstructions('Insert (record creation)'),
extraFields: buildServiceNowExtraFields('servicenow_incident_created'),
}),
outputs: buildIncidentOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,37 @@
import { ServiceNowIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildIncidentOutputs,
buildServiceNowExtraFields,
servicenowSetupInstructions,
servicenowTriggerOptions,
} from '@/triggers/servicenow/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* ServiceNow Incident Updated Trigger
*/
export const servicenowIncidentUpdatedTrigger: TriggerConfig = {
id: 'servicenow_incident_updated',
name: 'ServiceNow Incident Updated',
provider: 'servicenow',
description: 'Trigger workflow when an incident is updated in ServiceNow',
version: '1.0.0',
icon: ServiceNowIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'servicenow_incident_updated',
triggerOptions: servicenowTriggerOptions,
setupInstructions: servicenowSetupInstructions('Update (record modification)'),
extraFields: buildServiceNowExtraFields('servicenow_incident_updated'),
}),
outputs: buildIncidentOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,5 @@
export { servicenowChangeRequestCreatedTrigger } from './change_request_created'
export { servicenowChangeRequestUpdatedTrigger } from './change_request_updated'
export { servicenowIncidentCreatedTrigger } from './incident_created'
export { servicenowIncidentUpdatedTrigger } from './incident_updated'
export { servicenowWebhookTrigger } from './webhook'

View File

@@ -0,0 +1,280 @@
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
/**
* Shared trigger dropdown options for all ServiceNow triggers
*/
export const servicenowTriggerOptions = [
{ label: 'Incident Created', id: 'servicenow_incident_created' },
{ label: 'Incident Updated', id: 'servicenow_incident_updated' },
{ label: 'Change Request Created', id: 'servicenow_change_request_created' },
{ label: 'Change Request Updated', id: 'servicenow_change_request_updated' },
{ label: 'Generic Webhook (All Events)', id: 'servicenow_webhook' },
]
/**
* Generates setup instructions for ServiceNow webhooks.
* ServiceNow uses Business Rules with RESTMessageV2 for outbound webhooks.
*/
export function servicenowSetupInstructions(eventType: string): string {
const instructions = [
'<strong>Note:</strong> You need admin or developer permissions in your ServiceNow instance to create Business Rules.',
'Navigate to <strong>System Definition > Business Rules</strong> and create a new Business Rule.',
`Set the table (e.g., <strong>incident</strong>, <strong>change_request</strong>), set <strong>When</strong> to <strong>after</strong>, and check <strong>${eventType}</strong>.`,
'Check the <strong>Advanced</strong> checkbox to enable the script editor.',
'Copy the <strong>Webhook URL</strong> above and generate a <strong>Webhook Secret</strong> (any strong random string). Paste the secret in the <strong>Webhook Secret</strong> field here.',
`In the script, use <strong>RESTMessageV2</strong> to POST the record data as JSON to the <strong>Webhook URL</strong> above. Include the secret as <code>Authorization: Bearer &lt;your secret&gt;</code> or <code>X-Sim-Webhook-Secret: &lt;your secret&gt;</code>. Example:<br/><code style="font-size: 0.85em; display: block; margin-top: 4px; white-space: pre-wrap;">var r = new sn_ws.RESTMessageV2();\nr.setEndpoint("&lt;webhook_url&gt;");\nr.setHttpMethod("POST");\nr.setRequestHeader("Content-Type", "application/json");\nr.setRequestHeader("Authorization", "Bearer &lt;your_webhook_secret&gt;");\nr.setRequestBody(JSON.stringify({\n sysId: current.sys_id.toString(),\n number: current.number.toString(),\n shortDescription: current.short_description.toString(),\n state: current.state.toString(),\n priority: current.priority.toString()\n}));\nr.execute();</code>`,
'Activate the Business Rule and click "Save" above to activate your trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3">${index === 0 ? instruction : `<strong>${index}.</strong> ${instruction}`}</div>`
)
.join('')
}
/**
* Webhook secret field for ServiceNow triggers
*/
function servicenowWebhookSecretField(triggerId: string): SubBlockConfig {
return {
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Generate a secret and paste it here',
description:
'Required. Use the same value in your ServiceNow Business Rule as Bearer token or X-Sim-Webhook-Secret.',
password: true,
required: true,
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
}
}
/**
* Extra fields for ServiceNow triggers (webhook secret + optional table filter)
*/
export function buildServiceNowExtraFields(triggerId: string): SubBlockConfig[] {
return [
servicenowWebhookSecretField(triggerId),
{
id: 'tableName',
title: 'Table Name (Optional)',
type: 'short-input',
placeholder: 'e.g., incident, change_request',
description: 'Optionally filter to a specific ServiceNow table',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
/**
* Common record fields shared across ServiceNow trigger outputs
*/
function buildRecordOutputs(): Record<string, TriggerOutput> {
return {
sysId: { type: 'string', description: 'Unique system ID of the record' },
number: { type: 'string', description: 'Record number (e.g., INC0010001, CHG0010001)' },
tableName: { type: 'string', description: 'ServiceNow table name' },
shortDescription: { type: 'string', description: 'Short description of the record' },
description: { type: 'string', description: 'Full description of the record' },
state: { type: 'string', description: 'Current state of the record' },
priority: {
type: 'string',
description: 'Priority level (1=Critical, 2=High, 3=Moderate, 4=Low, 5=Planning)',
},
assignedTo: { type: 'string', description: 'User assigned to this record' },
assignmentGroup: { type: 'string', description: 'Group assigned to this record' },
createdBy: { type: 'string', description: 'User who created the record' },
createdOn: { type: 'string', description: 'When the record was created (ISO 8601)' },
updatedBy: { type: 'string', description: 'User who last updated the record' },
updatedOn: { type: 'string', description: 'When the record was last updated (ISO 8601)' },
}
}
/**
* Outputs for incident triggers
*/
export function buildIncidentOutputs(): Record<string, TriggerOutput> {
return {
...buildRecordOutputs(),
urgency: { type: 'string', description: 'Urgency level (1=High, 2=Medium, 3=Low)' },
impact: { type: 'string', description: 'Impact level (1=High, 2=Medium, 3=Low)' },
category: { type: 'string', description: 'Incident category' },
subcategory: { type: 'string', description: 'Incident subcategory' },
caller: { type: 'string', description: 'Caller/requester of the incident' },
resolvedBy: { type: 'string', description: 'User who resolved the incident' },
resolvedAt: { type: 'string', description: 'When the incident was resolved' },
closeNotes: { type: 'string', description: 'Notes added when the incident was closed' },
record: { type: 'json', description: 'Full incident record data' },
}
}
/**
* Outputs for change request triggers
*/
export function buildChangeRequestOutputs(): Record<string, TriggerOutput> {
return {
...buildRecordOutputs(),
type: { type: 'string', description: 'Change type (Normal, Standard, Emergency)' },
risk: { type: 'string', description: 'Risk level of the change' },
impact: { type: 'string', description: 'Impact level of the change' },
approval: { type: 'string', description: 'Approval status' },
startDate: { type: 'string', description: 'Planned start date' },
endDate: { type: 'string', description: 'Planned end date' },
category: { type: 'string', description: 'Change category' },
record: { type: 'json', description: 'Full change request record data' },
}
}
function normalizeToken(s: string): string {
return s
.trim()
.toLowerCase()
.replace(/[\s-]+/g, '_')
}
/**
* Extracts the table name from a ServiceNow webhook payload.
* Business Rule scripts can send tableName in multiple formats.
*/
function extractTableName(body: Record<string, unknown>): string | undefined {
const candidates = [body.tableName, body.table_name, body.table, body.sys_class_name]
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) {
return c.trim()
}
}
return undefined
}
/**
* Extracts the event type from a ServiceNow webhook payload.
*/
function extractEventType(body: Record<string, unknown>): string | undefined {
const candidates = [body.eventType, body.event_type, body.action, body.operation]
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) {
return c.trim()
}
}
return undefined
}
const INCIDENT_CREATED = new Set([
'incident_created',
'insert',
'created',
'create',
'after_insert',
'afterinsert',
])
const INCIDENT_UPDATED = new Set([
'incident_updated',
'update',
'updated',
'after_update',
'afterupdate',
])
const CHANGE_REQUEST_CREATED = new Set([
'change_request_created',
'insert',
'created',
'create',
'after_insert',
'afterinsert',
])
const CHANGE_REQUEST_UPDATED = new Set([
'change_request_updated',
'update',
'updated',
'after_update',
'afterupdate',
])
/**
* Checks whether a ServiceNow webhook payload matches the configured trigger.
* Used by the ServiceNow provider handler to filter events at runtime.
*/
export function isServiceNowEventMatch(
triggerId: string,
body: Record<string, unknown>,
configuredTableName?: string
): boolean {
const payloadTable = extractTableName(body)
const eventType = extractEventType(body)
if (triggerId === 'servicenow_webhook') {
if (!configuredTableName?.trim()) {
return true
}
if (!payloadTable) {
return true
}
return normalizeToken(payloadTable) === normalizeToken(configuredTableName)
}
if (triggerId === 'servicenow_incident_created' || triggerId === 'servicenow_incident_updated') {
if (configuredTableName?.trim()) {
if (payloadTable && normalizeToken(payloadTable) !== normalizeToken(configuredTableName)) {
return false
}
} else if (payloadTable && normalizeToken(payloadTable) !== 'incident') {
return false
}
if (!eventType) {
return true
}
const normalized = normalizeToken(eventType)
return triggerId === 'servicenow_incident_created'
? INCIDENT_CREATED.has(normalized)
: INCIDENT_UPDATED.has(normalized)
}
if (
triggerId === 'servicenow_change_request_created' ||
triggerId === 'servicenow_change_request_updated'
) {
if (configuredTableName?.trim()) {
if (payloadTable && normalizeToken(payloadTable) !== normalizeToken(configuredTableName)) {
return false
}
} else if (payloadTable && normalizeToken(payloadTable) !== 'change_request') {
return false
}
if (!eventType) {
return true
}
const normalized = normalizeToken(eventType)
return triggerId === 'servicenow_change_request_created'
? CHANGE_REQUEST_CREATED.has(normalized)
: CHANGE_REQUEST_UPDATED.has(normalized)
}
return true
}
/**
* Outputs for the generic webhook trigger (all events)
*/
export function buildServiceNowWebhookOutputs(): Record<string, TriggerOutput> {
return {
...buildRecordOutputs(),
eventType: {
type: 'string',
description: 'The type of event that triggered this workflow (e.g., insert, update, delete)',
},
category: { type: 'string', description: 'Record category' },
record: { type: 'json', description: 'Full record data from the webhook payload' },
}
}

View File

@@ -0,0 +1,38 @@
import { ServiceNowIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildServiceNowExtraFields,
buildServiceNowWebhookOutputs,
servicenowSetupInstructions,
servicenowTriggerOptions,
} from '@/triggers/servicenow/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Generic ServiceNow Webhook Trigger
* Captures all ServiceNow webhook events
*/
export const servicenowWebhookTrigger: TriggerConfig = {
id: 'servicenow_webhook',
name: 'ServiceNow Webhook (All Events)',
provider: 'servicenow',
description: 'Trigger workflow on any ServiceNow webhook event',
version: '1.0.0',
icon: ServiceNowIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'servicenow_webhook',
triggerOptions: servicenowTriggerOptions,
setupInstructions: servicenowSetupInstructions('Insert, Update, or Delete'),
extraFields: buildServiceNowExtraFields('servicenow_webhook'),
}),
outputs: buildServiceNowWebhookOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}