@@ -12,6 +12,10 @@ import { getServiceRegistry, Services } from '@ownpilot/core';
1212import { getLog } from '../services/log.js' ;
1313import { safeKeyCompare , apiError , apiResponse , ERROR_CODES , getErrorMessage } from './helpers.js' ;
1414import { TriggersRepository , type WebhookConfig } from '../db/repositories/triggers.js' ;
15+ import {
16+ WorkflowsRepository ,
17+ type TriggerNodeData ,
18+ } from '../db/repositories/workflows.js' ;
1519
1620const log = getLog ( 'Webhooks' ) ;
1721
@@ -245,3 +249,94 @@ webhookRoutes.post('/trigger/:triggerId', async (c) => {
245249
246250 return apiResponse ( c , { message : 'Webhook received' , triggerId } ) ;
247251} ) ;
252+
253+ /**
254+ * POST /webhooks/workflow/:path
255+ *
256+ * Receives external webhook calls that trigger workflows directly.
257+ * Matches the incoming path against workflow trigger nodes with
258+ * triggerType='webhook' and a matching webhookPath.
259+ *
260+ * Validates HMAC-SHA256 signature via X-Webhook-Signature header
261+ * if webhookSecret is configured on the trigger node.
262+ *
263+ * The webhook payload is injected as workflow input variables
264+ * under the __webhook namespace.
265+ */
266+ webhookRoutes . post ( '/workflow/:path' , async ( c ) => {
267+ const webhookPath = c . req . param ( 'path' ) ;
268+
269+ // Look up active workflow with matching webhookPath in its trigger node
270+ const repo = new WorkflowsRepository ( ) ;
271+ const workflow = await repo . getByWebhookPath ( `/hooks/${ webhookPath } ` ) ;
272+
273+ if ( ! workflow ) {
274+ return apiError (
275+ c ,
276+ { code : ERROR_CODES . NOT_FOUND , message : 'No workflow matches this webhook path' } ,
277+ 404
278+ ) ;
279+ }
280+
281+ // Find the trigger node to check for webhook secret
282+ const triggerNode = workflow . nodes . find ( ( n ) => n . type === 'triggerNode' ) ;
283+ const triggerData = triggerNode ?. data as TriggerNodeData | undefined ;
284+
285+ // HMAC-SHA256 signature validation if webhookSecret is configured
286+ if ( triggerData ?. webhookSecret ) {
287+ const signature = c . req . header ( 'x-webhook-signature' ) ;
288+ if ( ! signature ) {
289+ return apiError (
290+ c ,
291+ { code : ERROR_CODES . ACCESS_DENIED , message : 'Missing X-Webhook-Signature header' } ,
292+ 403
293+ ) ;
294+ }
295+
296+ const rawBody = await c . req . text ( ) ;
297+ const expected = createHmac ( 'sha256' , triggerData . webhookSecret ) . update ( rawBody ) . digest ( 'hex' ) ;
298+
299+ if ( ! safeKeyCompare ( signature , expected ) ) {
300+ return apiError (
301+ c ,
302+ { code : ERROR_CODES . ACCESS_DENIED , message : 'Invalid webhook signature' } ,
303+ 403
304+ ) ;
305+ }
306+ }
307+
308+ // Parse the webhook body
309+ let body : Record < string , unknown > = { } ;
310+ try {
311+ body = ( await c . req . json ( ) ) as Record < string , unknown > ;
312+ } catch {
313+ /* empty body is fine */
314+ }
315+
316+ // Execute the workflow with webhook data as input
317+ try {
318+ const service = getServiceRegistry ( ) . get ( Services . Workflow ) ;
319+ const userId = workflow . userId ?? 'default' ;
320+
321+ // Fire-and-forget: execute in background, don't block the webhook response
322+ service
323+ . executeWorkflow ( workflow . id , userId , undefined , {
324+ inputs : { __webhook : { path : webhookPath , body, receivedAt : new Date ( ) . toISOString ( ) } } ,
325+ } )
326+ . catch ( ( err : Error ) =>
327+ log . error ( 'Webhook workflow execution failed' , { workflowId : workflow . id , error : err . message } )
328+ ) ;
329+
330+ return apiResponse ( c , {
331+ message : 'Webhook received, workflow triggered' ,
332+ workflowId : workflow . id ,
333+ } ) ;
334+ } catch ( error ) {
335+ log . error ( 'Webhook workflow trigger failed' , { error : getErrorMessage ( error ) } ) ;
336+ return apiError (
337+ c ,
338+ { code : ERROR_CODES . INTERNAL_ERROR , message : 'Failed to trigger workflow' } ,
339+ 500
340+ ) ;
341+ }
342+ } ) ;
0 commit comments