Skip to content

Commit 77712e6

Browse files
waleedlatif1claude
andcommitted
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>
1 parent bb3aee3 commit 77712e6

File tree

3 files changed

+186
-0
lines changed

3 files changed

+186
-0
lines changed

apps/sim/lib/webhooks/providers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { outlookHandler } from '@/lib/webhooks/providers/outlook'
2828
import { resendHandler } from '@/lib/webhooks/providers/resend'
2929
import { rssHandler } from '@/lib/webhooks/providers/rss'
3030
import { salesforceHandler } from '@/lib/webhooks/providers/salesforce'
31+
import { servicenowHandler } from '@/lib/webhooks/providers/servicenow'
3132
import { slackHandler } from '@/lib/webhooks/providers/slack'
3233
import { stripeHandler } from '@/lib/webhooks/providers/stripe'
3334
import { telegramHandler } from '@/lib/webhooks/providers/telegram'
@@ -72,6 +73,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
7273
outlook: outlookHandler,
7374
rss: rssHandler,
7475
salesforce: salesforceHandler,
76+
servicenow: servicenowHandler,
7577
slack: slackHandler,
7678
stripe: stripeHandler,
7779
telegram: telegramHandler,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createLogger } from '@sim/logger'
2+
import { NextResponse } from 'next/server'
3+
import type { AuthContext, EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
4+
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
5+
6+
const logger = createLogger('WebhookProvider:ServiceNow')
7+
8+
function asRecord(body: unknown): Record<string, unknown> {
9+
return body && typeof body === 'object' && !Array.isArray(body)
10+
? (body as Record<string, unknown>)
11+
: {}
12+
}
13+
14+
export const servicenowHandler: WebhookProviderHandler = {
15+
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
16+
const secret = providerConfig.webhookSecret as string | undefined
17+
if (!secret?.trim()) {
18+
logger.warn(`[${requestId}] ServiceNow webhook missing webhookSecret — rejecting`)
19+
return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 })
20+
}
21+
22+
if (
23+
!verifyTokenAuth(request, secret.trim(), 'x-sim-webhook-secret') &&
24+
!verifyTokenAuth(request, secret.trim())
25+
) {
26+
logger.warn(`[${requestId}] ServiceNow webhook secret verification failed`)
27+
return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 })
28+
}
29+
30+
return null
31+
},
32+
33+
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
34+
const triggerId = providerConfig.triggerId as string | undefined
35+
if (!triggerId) {
36+
return true
37+
}
38+
39+
const { isServiceNowEventMatch } = await import('@/triggers/servicenow/utils')
40+
const configuredTableName = providerConfig.tableName as string | undefined
41+
const obj = asRecord(body)
42+
43+
if (!isServiceNowEventMatch(triggerId, obj, configuredTableName)) {
44+
logger.debug(
45+
`[${requestId}] ServiceNow event mismatch for trigger ${triggerId}. Skipping execution.`,
46+
{ webhookId: webhook.id, workflowId: workflow.id, triggerId }
47+
)
48+
return false
49+
}
50+
51+
return true
52+
},
53+
}

apps/sim/triggers/servicenow/utils.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,137 @@ export function buildChangeRequestOutputs(): Record<string, TriggerOutput> {
130130
}
131131
}
132132

133+
function normalizeToken(s: string): string {
134+
return s.trim().toLowerCase().replace(/[\s-]+/g, '_')
135+
}
136+
137+
/**
138+
* Extracts the table name from a ServiceNow webhook payload.
139+
* Business Rule scripts can send tableName in multiple formats.
140+
*/
141+
function extractTableName(body: Record<string, unknown>): string | undefined {
142+
const candidates = [body.tableName, body.table_name, body.table, body.sys_class_name]
143+
for (const c of candidates) {
144+
if (typeof c === 'string' && c.trim()) {
145+
return c.trim()
146+
}
147+
}
148+
return undefined
149+
}
150+
151+
/**
152+
* Extracts the event type from a ServiceNow webhook payload.
153+
*/
154+
function extractEventType(body: Record<string, unknown>): string | undefined {
155+
const candidates = [body.eventType, body.event_type, body.action, body.operation]
156+
for (const c of candidates) {
157+
if (typeof c === 'string' && c.trim()) {
158+
return c.trim()
159+
}
160+
}
161+
return undefined
162+
}
163+
164+
const INCIDENT_CREATED = new Set([
165+
'incident_created',
166+
'insert',
167+
'created',
168+
'create',
169+
'after_insert',
170+
'afterinsert',
171+
])
172+
173+
const INCIDENT_UPDATED = new Set([
174+
'incident_updated',
175+
'update',
176+
'updated',
177+
'after_update',
178+
'afterupdate',
179+
])
180+
181+
const CHANGE_REQUEST_CREATED = new Set([
182+
'change_request_created',
183+
'insert',
184+
'created',
185+
'create',
186+
'after_insert',
187+
'afterinsert',
188+
])
189+
190+
const CHANGE_REQUEST_UPDATED = new Set([
191+
'change_request_updated',
192+
'update',
193+
'updated',
194+
'after_update',
195+
'afterupdate',
196+
])
197+
198+
/**
199+
* Checks whether a ServiceNow webhook payload matches the configured trigger.
200+
* Used by the ServiceNow provider handler to filter events at runtime.
201+
*/
202+
export function isServiceNowEventMatch(
203+
triggerId: string,
204+
body: Record<string, unknown>,
205+
configuredTableName?: string
206+
): boolean {
207+
const payloadTable = extractTableName(body)
208+
const eventType = extractEventType(body)
209+
210+
if (triggerId === 'servicenow_webhook') {
211+
if (!configuredTableName?.trim()) {
212+
return true
213+
}
214+
if (!payloadTable) {
215+
return true
216+
}
217+
return normalizeToken(payloadTable) === normalizeToken(configuredTableName)
218+
}
219+
220+
if (triggerId === 'servicenow_incident_created' || triggerId === 'servicenow_incident_updated') {
221+
if (configuredTableName?.trim()) {
222+
if (payloadTable && normalizeToken(payloadTable) !== normalizeToken(configuredTableName)) {
223+
return false
224+
}
225+
} else if (payloadTable && normalizeToken(payloadTable) !== 'incident') {
226+
return false
227+
}
228+
229+
if (!eventType) {
230+
return true
231+
}
232+
233+
const normalized = normalizeToken(eventType)
234+
return triggerId === 'servicenow_incident_created'
235+
? INCIDENT_CREATED.has(normalized)
236+
: INCIDENT_UPDATED.has(normalized)
237+
}
238+
239+
if (
240+
triggerId === 'servicenow_change_request_created' ||
241+
triggerId === 'servicenow_change_request_updated'
242+
) {
243+
if (configuredTableName?.trim()) {
244+
if (payloadTable && normalizeToken(payloadTable) !== normalizeToken(configuredTableName)) {
245+
return false
246+
}
247+
} else if (payloadTable && normalizeToken(payloadTable) !== 'change_request') {
248+
return false
249+
}
250+
251+
if (!eventType) {
252+
return true
253+
}
254+
255+
const normalized = normalizeToken(eventType)
256+
return triggerId === 'servicenow_change_request_created'
257+
? CHANGE_REQUEST_CREATED.has(normalized)
258+
: CHANGE_REQUEST_UPDATED.has(normalized)
259+
}
260+
261+
return true
262+
}
263+
133264
/**
134265
* Outputs for the generic webhook trigger (all events)
135266
*/

0 commit comments

Comments
 (0)