Skip to content

Commit 5aa6d78

Browse files
committed
feat(triggers): auto-register GitLab, PagerDuty, and Zendesk webhooks
Replace the manual-registration model with automatic webhook creation on deploy and cleanup on undeploy, via createSubscription/deleteSubscription on each provider handler: - GitLab: POST /projects/:id/hooks with a Personal Access Token; generates the secret token (stored for X-Gitlab-Token verification) and enables only the event flags for the selected trigger. Deletes the hook on undeploy. - PagerDuty: POST /webhook_subscriptions (account-scoped) with a REST API key; captures delivery_method.secret (returned only on create) for X-PagerDuty-Signature verification. Deletes the subscription on undeploy. - Zendesk: POST /api/v2/webhooks with native event subscriptions, then GET /webhooks/:id/signing_secret for X-Zendesk-Webhook-Signature verification. Deletes the webhook on undeploy. Trigger config now collects the provider credentials (user-only) instead of a pasted signing secret; the signing secret is generated or fetched and stored in providerConfig by the orchestration layer (no route/deploy changes).
1 parent c2e7e01 commit 5aa6d78

12 files changed

Lines changed: 410 additions & 48 deletions

File tree

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
import { createLogger } from '@sim/logger'
22
import { safeCompare } from '@sim/security/compare'
3+
import { generateId } from '@sim/utils/id'
34
import { NextResponse } from 'next/server'
5+
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
46
import type {
57
AuthContext,
8+
DeleteSubscriptionContext,
69
EventMatchContext,
710
FormatInputContext,
811
FormatInputResult,
12+
SubscriptionContext,
13+
SubscriptionResult,
914
WebhookProviderHandler,
1015
} from '@/lib/webhooks/providers/types'
1116

1217
const logger = createLogger('WebhookProvider:GitLab')
1318

19+
const GITLAB_API_BASE = 'https://gitlab.com/api/v4'
20+
1421
function asRecord(value: unknown): Record<string, unknown> {
1522
return (value as Record<string, unknown>) || {}
1623
}
1724

25+
function gitlabProjectHooksUrl(projectId: string): string {
26+
return `${GITLAB_API_BASE}/projects/${encodeURIComponent(projectId)}/hooks`
27+
}
28+
1829
export const gitlabHandler: WebhookProviderHandler = {
1930
/**
2031
* GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token`
@@ -65,4 +76,80 @@ export const gitlabHandler: WebhookProviderHandler = {
6576
input: { ...b, event_type: eventType, branch },
6677
}
6778
},
79+
80+
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
81+
const config = getProviderConfig(ctx.webhook)
82+
const accessToken = config.accessToken as string | undefined
83+
const projectId = config.projectId as string | undefined
84+
const triggerId = config.triggerId as string | undefined
85+
86+
if (!accessToken)
87+
throw new Error('GitLab Personal Access Token is required to create the webhook.')
88+
if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.')
89+
90+
const { getGitLabEventFlags } = await import('@/triggers/gitlab/utils')
91+
const secretToken = generateId()
92+
const res = await fetch(gitlabProjectHooksUrl(projectId), {
93+
method: 'POST',
94+
headers: { 'PRIVATE-TOKEN': accessToken, 'Content-Type': 'application/json' },
95+
body: JSON.stringify({
96+
url: getNotificationUrl(ctx.webhook),
97+
token: secretToken,
98+
enable_ssl_verification: true,
99+
...getGitLabEventFlags(triggerId ?? 'gitlab_webhook'),
100+
}),
101+
})
102+
103+
if (!res.ok) {
104+
const detail = await res.text().catch(() => '')
105+
logger.error(`[${ctx.requestId}] Failed to create GitLab webhook (${res.status})`, { detail })
106+
if (res.status === 401)
107+
throw new Error(
108+
'GitLab authentication failed. Verify your Personal Access Token has the api scope.'
109+
)
110+
if (res.status === 403)
111+
throw new Error(
112+
'GitLab access denied. You need the Maintainer or Owner role on the project.'
113+
)
114+
if (res.status === 404) throw new Error('GitLab project not found. Verify the Project ID.')
115+
throw new Error(`Failed to create GitLab webhook: ${res.status}`)
116+
}
117+
118+
const created = (await res.json().catch(() => ({}))) as { id?: number | string }
119+
if (created.id === undefined || created.id === null) {
120+
throw new Error('GitLab webhook created but no hook ID was returned.')
121+
}
122+
123+
logger.info(`[${ctx.requestId}] Created GitLab webhook ${created.id} for project ${projectId}`)
124+
return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } }
125+
},
126+
127+
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
128+
const config = getProviderConfig(ctx.webhook)
129+
const accessToken = config.accessToken as string | undefined
130+
const projectId = config.projectId as string | undefined
131+
const externalId = config.externalId as string | undefined
132+
133+
if (!accessToken || !projectId || !externalId) {
134+
if (ctx.strict) throw new Error('Missing GitLab credentials or hook ID for webhook deletion.')
135+
logger.warn(
136+
`[${ctx.requestId}] Skipping GitLab webhook cleanup — missing token, project, or hook ID`
137+
)
138+
return
139+
}
140+
141+
const res = await fetch(`${gitlabProjectHooksUrl(projectId)}/${externalId}`, {
142+
method: 'DELETE',
143+
headers: { 'PRIVATE-TOKEN': accessToken },
144+
})
145+
146+
if (!res.ok && res.status !== 404) {
147+
if (ctx.strict) throw new Error(`Failed to delete GitLab webhook: ${res.status}`)
148+
logger.warn(
149+
`[${ctx.requestId}] Failed to delete GitLab webhook ${externalId} (non-fatal): ${res.status}`
150+
)
151+
return
152+
}
153+
logger.info(`[${ctx.requestId}] Deleted GitLab webhook ${externalId}`)
154+
},
68155
}

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
11
import crypto from 'crypto'
22
import { createLogger } from '@sim/logger'
33
import { safeCompare } from '@sim/security/compare'
4+
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
45
import type {
6+
DeleteSubscriptionContext,
57
EventMatchContext,
68
FormatInputContext,
79
FormatInputResult,
10+
SubscriptionContext,
11+
SubscriptionResult,
812
WebhookProviderHandler,
913
} from '@/lib/webhooks/providers/types'
1014
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
1115

1216
const logger = createLogger('WebhookProvider:PagerDuty')
1317

18+
const PAGERDUTY_API_BASE = 'https://api.pagerduty.com'
19+
20+
/** Shared headers for PagerDuty REST API calls (the v2 Accept header is required). */
21+
function pagerdutyHeaders(apiKey: string): Record<string, string> {
22+
return {
23+
Authorization: `Token token=${apiKey}`,
24+
'Content-Type': 'application/json',
25+
Accept: 'application/vnd.pagerduty+json;version=2',
26+
}
27+
}
28+
1429
/**
1530
* PagerDuty V3 signs the raw body with HMAC-SHA256 and sends it in the
1631
* `X-PagerDuty-Signature` header as one or more comma-separated `v1=<hex>`
@@ -96,4 +111,81 @@ export const pagerdutyHandler: WebhookProviderHandler = {
96111
const event = asRecord(asRecord(body).event)
97112
return (event.id as string | undefined) || null
98113
},
114+
115+
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
116+
const config = getProviderConfig(ctx.webhook)
117+
const apiKey = config.apiKey as string | undefined
118+
const triggerId = config.triggerId as string | undefined
119+
120+
if (!apiKey)
121+
throw new Error('PagerDuty API Key is required to create the webhook subscription.')
122+
123+
const { getPagerDutyEvents } = await import('@/triggers/pagerduty/utils')
124+
const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, {
125+
method: 'POST',
126+
headers: pagerdutyHeaders(apiKey),
127+
body: JSON.stringify({
128+
webhook_subscription: {
129+
type: 'webhook_subscription',
130+
delivery_method: { type: 'http_delivery_method', url: getNotificationUrl(ctx.webhook) },
131+
events: getPagerDutyEvents(triggerId ?? 'pagerduty_webhook'),
132+
filter: { type: 'account_reference' },
133+
},
134+
}),
135+
})
136+
137+
if (!res.ok) {
138+
const detail = await res.text().catch(() => '')
139+
logger.error(`[${ctx.requestId}] Failed to create PagerDuty webhook (${res.status})`, {
140+
detail,
141+
})
142+
if (res.status === 401)
143+
throw new Error('PagerDuty authentication failed. Verify your REST API key.')
144+
if (res.status === 403)
145+
throw new Error('PagerDuty access denied. The API key must have read/write access.')
146+
throw new Error(`Failed to create PagerDuty webhook subscription: ${res.status}`)
147+
}
148+
149+
const created = asRecord((await res.json().catch(() => ({}))) as unknown)
150+
const subscription = asRecord(created.webhook_subscription)
151+
const externalId = subscription.id as string | undefined
152+
const secret = asRecord(subscription.delivery_method).secret as string | undefined
153+
154+
if (!externalId)
155+
throw new Error('PagerDuty webhook created but no subscription ID was returned.')
156+
if (!secret) {
157+
throw new Error('PagerDuty webhook created but no signing secret was returned on creation.')
158+
}
159+
160+
logger.info(`[${ctx.requestId}] Created PagerDuty webhook subscription ${externalId}`)
161+
return { providerConfigUpdates: { externalId, webhookSecret: secret } }
162+
},
163+
164+
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
165+
const config = getProviderConfig(ctx.webhook)
166+
const apiKey = config.apiKey as string | undefined
167+
const externalId = config.externalId as string | undefined
168+
169+
if (!apiKey || !externalId) {
170+
if (ctx.strict) throw new Error('Missing PagerDuty API key or subscription ID for deletion.')
171+
logger.warn(
172+
`[${ctx.requestId}] Skipping PagerDuty webhook cleanup — missing API key or subscription ID`
173+
)
174+
return
175+
}
176+
177+
const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${externalId}`, {
178+
method: 'DELETE',
179+
headers: pagerdutyHeaders(apiKey),
180+
})
181+
182+
if (!res.ok && res.status !== 404) {
183+
if (ctx.strict) throw new Error(`Failed to delete PagerDuty webhook: ${res.status}`)
184+
logger.warn(
185+
`[${ctx.requestId}] Failed to delete PagerDuty webhook ${externalId} (non-fatal): ${res.status}`
186+
)
187+
return
188+
}
189+
logger.info(`[${ctx.requestId}] Deleted PagerDuty webhook subscription ${externalId}`)
190+
},
99191
}

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import crypto from 'crypto'
22
import { createLogger } from '@sim/logger'
33
import { safeCompare } from '@sim/security/compare'
44
import { NextResponse } from 'next/server'
5+
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
56
import type {
67
AuthContext,
8+
DeleteSubscriptionContext,
79
EventMatchContext,
810
FormatInputContext,
911
FormatInputResult,
12+
SubscriptionContext,
13+
SubscriptionResult,
1014
WebhookProviderHandler,
1115
} from '@/lib/webhooks/providers/types'
1216

@@ -16,6 +20,16 @@ function asRecord(value: unknown): Record<string, unknown> {
1620
return (value as Record<string, unknown>) || {}
1721
}
1822

23+
/** Zendesk API base for a subdomain. */
24+
function zendeskApiBase(subdomain: string): string {
25+
return `https://${subdomain}.zendesk.com/api/v2`
26+
}
27+
28+
/** Basic auth header for the Zendesk API-token scheme (`email/token:apiToken`). */
29+
function zendeskAuthHeader(email: string, apiToken: string): string {
30+
return `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}`
31+
}
32+
1933
/** Maximum allowed clock skew (5 minutes) between Zendesk's signed timestamp and now, per Zendesk docs. */
2034
const ZENDESK_TIMESTAMP_MAX_SKEW_MS = 5 * 60 * 1000
2135

@@ -130,4 +144,101 @@ export const zendeskHandler: WebhookProviderHandler = {
130144
extractIdempotencyId(body: unknown) {
131145
return (asRecord(body).id as string | undefined) || null
132146
},
147+
148+
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
149+
const config = getProviderConfig(ctx.webhook)
150+
const subdomain = config.subdomain as string | undefined
151+
const email = config.email as string | undefined
152+
const apiToken = config.apiToken as string | undefined
153+
const triggerId = config.triggerId as string | undefined
154+
155+
if (!subdomain) throw new Error('Zendesk subdomain is required to create the webhook.')
156+
if (!email) throw new Error('Zendesk admin email is required to create the webhook.')
157+
if (!apiToken) throw new Error('Zendesk API token is required to create the webhook.')
158+
159+
const { getZendeskSubscriptions } = await import('@/triggers/zendesk/utils')
160+
const apiBase = zendeskApiBase(subdomain)
161+
const authHeader = zendeskAuthHeader(email, apiToken)
162+
163+
const createRes = await fetch(`${apiBase}/webhooks`, {
164+
method: 'POST',
165+
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
166+
body: JSON.stringify({
167+
webhook: {
168+
name: `Sim webhook (${ctx.webhook.id})`,
169+
endpoint: getNotificationUrl(ctx.webhook),
170+
http_method: 'POST',
171+
request_format: 'json',
172+
status: 'active',
173+
subscriptions: getZendeskSubscriptions(triggerId ?? 'zendesk_webhook'),
174+
},
175+
}),
176+
})
177+
178+
if (!createRes.ok) {
179+
const detail = await createRes.text().catch(() => '')
180+
logger.error(`[${ctx.requestId}] Failed to create Zendesk webhook (${createRes.status})`, {
181+
detail,
182+
})
183+
if (createRes.status === 401 || createRes.status === 403) {
184+
throw new Error(
185+
'Zendesk authentication failed. Verify the subdomain, admin email, and API token.'
186+
)
187+
}
188+
throw new Error(`Failed to create Zendesk webhook: ${createRes.status}`)
189+
}
190+
191+
const created = asRecord((await createRes.json().catch(() => ({}))) as unknown)
192+
const externalId = asRecord(created.webhook).id as string | undefined
193+
if (!externalId) throw new Error('Zendesk webhook created but no webhook ID was returned.')
194+
195+
const secretRes = await fetch(`${apiBase}/webhooks/${externalId}/signing_secret`, {
196+
headers: { Authorization: authHeader },
197+
})
198+
if (!secretRes.ok) {
199+
const detail = await secretRes.text().catch(() => '')
200+
logger.error(
201+
`[${ctx.requestId}] Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`,
202+
{ detail }
203+
)
204+
throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`)
205+
}
206+
207+
const secretBody = asRecord((await secretRes.json().catch(() => ({}))) as unknown)
208+
const secret = asRecord(secretBody.signing_secret).secret as string | undefined
209+
if (!secret) throw new Error('Zendesk did not return a signing secret for the webhook.')
210+
211+
logger.info(`[${ctx.requestId}] Created Zendesk webhook ${externalId}`)
212+
return { providerConfigUpdates: { externalId, webhookSecret: secret } }
213+
},
214+
215+
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
216+
const config = getProviderConfig(ctx.webhook)
217+
const subdomain = config.subdomain as string | undefined
218+
const email = config.email as string | undefined
219+
const apiToken = config.apiToken as string | undefined
220+
const externalId = config.externalId as string | undefined
221+
222+
if (!subdomain || !email || !apiToken || !externalId) {
223+
if (ctx.strict) throw new Error('Missing Zendesk credentials or webhook ID for deletion.')
224+
logger.warn(
225+
`[${ctx.requestId}] Skipping Zendesk webhook cleanup — missing credentials or webhook ID`
226+
)
227+
return
228+
}
229+
230+
const res = await fetch(`${zendeskApiBase(subdomain)}/webhooks/${externalId}`, {
231+
method: 'DELETE',
232+
headers: { Authorization: zendeskAuthHeader(email, apiToken) },
233+
})
234+
235+
if (!res.ok && res.status !== 404) {
236+
if (ctx.strict) throw new Error(`Failed to delete Zendesk webhook: ${res.status}`)
237+
logger.warn(
238+
`[${ctx.requestId}] Failed to delete Zendesk webhook ${externalId} (non-fatal): ${res.status}`
239+
)
240+
return
241+
}
242+
logger.info(`[${ctx.requestId}] Deleted Zendesk webhook ${externalId}`)
243+
},
133244
}

apps/sim/triggers/gitlab/comment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const gitlabCommentTrigger: TriggerConfig = {
1818
subBlocks: buildTriggerSubBlocks({
1919
triggerId: 'gitlab_comment',
2020
triggerOptions: gitlabTriggerOptions,
21-
setupInstructions: gitlabSetupInstructions('Comment', 'Comments'),
21+
setupInstructions: gitlabSetupInstructions('Comment'),
2222
extraFields: buildGitLabExtraFields('gitlab_comment'),
2323
}),
2424
outputs: buildGitLabCommentOutputs(),

apps/sim/triggers/gitlab/issue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const gitlabIssueTrigger: TriggerConfig = {
1818
subBlocks: buildTriggerSubBlocks({
1919
triggerId: 'gitlab_issue',
2020
triggerOptions: gitlabTriggerOptions,
21-
setupInstructions: gitlabSetupInstructions('Issue', 'Issues events'),
21+
setupInstructions: gitlabSetupInstructions('Issue'),
2222
extraFields: buildGitLabExtraFields('gitlab_issue'),
2323
}),
2424
outputs: buildGitLabIssueOutputs(),

apps/sim/triggers/gitlab/merge_request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const gitlabMergeRequestTrigger: TriggerConfig = {
1818
subBlocks: buildTriggerSubBlocks({
1919
triggerId: 'gitlab_merge_request',
2020
triggerOptions: gitlabTriggerOptions,
21-
setupInstructions: gitlabSetupInstructions('Merge Request', 'Merge request events'),
21+
setupInstructions: gitlabSetupInstructions('Merge Request'),
2222
extraFields: buildGitLabExtraFields('gitlab_merge_request'),
2323
}),
2424
outputs: buildGitLabMergeRequestOutputs(),

apps/sim/triggers/gitlab/pipeline.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const gitlabPipelineTrigger: TriggerConfig = {
1818
subBlocks: buildTriggerSubBlocks({
1919
triggerId: 'gitlab_pipeline',
2020
triggerOptions: gitlabTriggerOptions,
21-
setupInstructions: gitlabSetupInstructions('Pipeline', 'Pipeline events'),
21+
setupInstructions: gitlabSetupInstructions('Pipeline'),
2222
extraFields: buildGitLabExtraFields('gitlab_pipeline'),
2323
}),
2424
outputs: buildGitLabPipelineOutputs(),

0 commit comments

Comments
 (0)