import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { tryAdmit } from '@/lib/core/admission/gate' import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import { assertContentLengthWithinLimit, isPayloadSizeLimitError, readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { preprocessExecution } from '@/lib/execution/preprocessing' import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' import { getPendingWebhookVerification, matchesPendingWebhookVerificationProbe, requiresPendingWebhookVerification, } from '@/lib/webhooks/pending-verification' import { getProviderHandler } from '@/lib/webhooks/providers' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' import { SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants' import { executeWebhookJob } from '@/background/webhook-execution' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import { isPollingWebhookProvider } from '@/triggers/constants' const logger = createLogger('WebhookProcessor') export interface WebhookProcessorOptions { requestId: string path?: string webhookId?: string actorUserId?: string executionId?: string correlation?: AsyncExecutionCorrelation /** Epoch ms when the webhook HTTP request was first received (for dispatch-latency metrics). */ receivedAt?: number /** Epoch ms of the originating provider interaction (e.g. Slack x-slack-request-timestamp). */ triggerTimestampMs?: number } export interface WebhookPreprocessingResult { error: NextResponse | null actorUserId?: string executionId?: string correlation?: AsyncExecutionCorrelation } const WEBHOOK_BODY_LABEL = 'Webhook request body' export async function parseWebhookBody( request: NextRequest, requestId: string ): Promise<{ body: unknown; rawBody: string } | NextResponse> { let rawBody: string | null = null try { assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL) const buffer = await readStreamToBufferWithLimit(request.clone().body, { maxBytes: WEBHOOK_MAX_BODY_BYTES, label: WEBHOOK_BODY_LABEL, }) rawBody = new TextDecoder().decode(buffer) if (!rawBody || rawBody.length === 0) { return { body: {}, rawBody: '' } } } catch (bodyError) { if (isPayloadSizeLimitError(bodyError)) { logger.warn(`[${requestId}] Rejected oversized webhook body`, { maxBytes: WEBHOOK_MAX_BODY_BYTES, observedBytes: bodyError.observedBytes, }) return new NextResponse('Request body too large', { status: 413 }) } logger.error(`[${requestId}] Failed to read request body`, { error: toError(bodyError).message, }) return new NextResponse('Failed to read request body', { status: 400 }) } let body: unknown try { const contentType = request.headers.get('content-type') || '' if (contentType.includes('application/x-www-form-urlencoded')) { const formData = new URLSearchParams(rawBody) const payloadString = formData.get('payload') if (payloadString) { body = JSON.parse(payloadString) } else { body = Object.fromEntries(formData.entries()) } } else { body = JSON.parse(rawBody) } } catch (parseError) { logger.error(`[${requestId}] Failed to parse webhook body`, { error: toError(parseError).message, contentType: request.headers.get('content-type'), bodyPreview: truncate(rawBody ?? '', 100), }) return new NextResponse('Invalid payload format', { status: 400 }) } return { body, rawBody } } /** Providers that implement challenge/verification handling, checked before webhook lookup. */ const CHALLENGE_PROVIDERS = ['monday', 'slack', 'microsoft-teams', 'whatsapp', 'zoom'] as const export async function handleProviderChallenges( body: unknown, request: NextRequest, requestId: string, path: string, rawBody?: string ): Promise { for (const provider of CHALLENGE_PROVIDERS) { const handler = getProviderHandler(provider) if (handler.handleChallenge) { const response = await handler.handleChallenge(body, request, requestId, path, rawBody) if (response) { return response } } } return null } /** * Returns a verification response for provider reachability probes that happen * before a webhook row exists and therefore before provider lookup is possible. */ export async function handlePreLookupWebhookVerification( method: string, body: Record | undefined, requestId: string, path: string ): Promise { const pendingVerification = await getPendingWebhookVerification(path) if (!pendingVerification) { return null } if (!matchesPendingWebhookVerificationProbe(pendingVerification, { method, body })) { return null } logger.info( `[${requestId}] Returning 200 for pending ${pendingVerification.provider} webhook verification on path: ${path}` ) return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' }) } /** * Handle provider-specific reachability tests that occur AFTER webhook lookup. * Delegates to the provider handler registry. */ export function handleProviderReachabilityTest( webhookRecord: { provider: string }, body: unknown, requestId: string ): NextResponse | null { const handler = getProviderHandler(webhookRecord?.provider) return handler.handleReachabilityTest?.(body, requestId) ?? null } /** * Format error response based on provider requirements. * Delegates to the provider handler registry. */ export function formatProviderErrorResponse( webhookRecord: { provider: string }, error: string, status: number ): NextResponse { const handler = getProviderHandler(webhookRecord.provider) return handler.formatErrorResponse?.(error, status) ?? NextResponse.json({ error }, { status }) } /** * Check if a webhook event should be skipped based on provider-specific filtering. * Delegates to the provider handler registry. */ export function shouldSkipWebhookEvent( webhookRecord: { provider: string; providerConfig?: Record }, body: unknown, requestId: string ): boolean { const handler = getProviderHandler(webhookRecord.provider) const providerConfig = webhookRecord.providerConfig ?? {} return ( handler.shouldSkipEvent?.({ webhook: webhookRecord, body, requestId, providerConfig }) ?? false ) } /** Returns 200 OK for providers that validate URLs before the workflow is deployed */ export function handlePreDeploymentVerification( webhookRecord: { provider: string }, requestId: string ): NextResponse | null { if (requiresPendingWebhookVerification(webhookRecord.provider)) { logger.info( `[${requestId}] ${webhookRecord.provider} webhook - block not in deployment, returning 200 OK for URL validation` ) return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified', }) } return null } async function findWebhookAndWorkflow( options: WebhookProcessorOptions ): Promise<{ webhook: any; workflow: any } | null> { if (options.webhookId) { const results = await db .select({ webhook: webhook, workflow: workflow, }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) .leftJoin( workflowDeploymentVersion, and( eq(workflowDeploymentVersion.workflowId, workflow.id), eq(workflowDeploymentVersion.isActive, true) ) ) .where( and( eq(webhook.id, options.webhookId), eq(webhook.isActive, true), isNull(webhook.archivedAt), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) ) ) ) .limit(1) if (results.length === 0) { logger.warn(`[${options.requestId}] No active webhook found for id: ${options.webhookId}`) return null } return { webhook: results[0].webhook, workflow: results[0].workflow } } if (options.path) { const results = await db .select({ webhook: webhook, workflow: workflow, }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) .leftJoin( workflowDeploymentVersion, and( eq(workflowDeploymentVersion.workflowId, workflow.id), eq(workflowDeploymentVersion.isActive, true) ) ) .where( and( eq(webhook.path, options.path), eq(webhook.isActive, true), isNull(webhook.archivedAt), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) ) ) ) .limit(1) if (results.length === 0) { logger.warn(`[${options.requestId}] No active webhook found for path: ${options.path}`) return null } return { webhook: results[0].webhook, workflow: results[0].workflow } } return null } /** * Finds all webhooks matching a path, scoped to a single workflow. * * Legitimate multi-webhook matches are always within one workflow, but paths * are user-controlled and only unique per deployment version, so two tenants can * register the same path. On collision we keep only the workflow that registered * the path first, so one tenant can never receive another's webhook deliveries. */ export async function findAllWebhooksForPath( options: WebhookProcessorOptions ): Promise> { if (!options.path) { return [] } const results = await db .select({ webhook: webhook, workflow: workflow, }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) .leftJoin( workflowDeploymentVersion, and( eq(workflowDeploymentVersion.workflowId, workflow.id), eq(workflowDeploymentVersion.isActive, true) ) ) .where( and( eq(webhook.path, options.path), eq(webhook.isActive, true), isNull(webhook.archivedAt), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) ) ) ) if (results.length === 0) { logger.warn(`[${options.requestId}] No active webhooks found for path: ${options.path}`) return results } const distinctWorkflowIds = new Set(results.map((result) => result.webhook.workflowId)) if (distinctWorkflowIds.size > 1) { const owner = results.reduce((earliest, candidate) => { const candidateTime = new Date(candidate.webhook.createdAt).getTime() const earliestTime = new Date(earliest.webhook.createdAt).getTime() if (candidateTime !== earliestTime) { return candidateTime < earliestTime ? candidate : earliest } return candidate.webhook.id < earliest.webhook.id ? candidate : earliest }) const ownerWorkflowId = owner.webhook.workflowId const ownerResults = results.filter((result) => result.webhook.workflowId === ownerWorkflowId) logger.error( `[${options.requestId}] Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} and dropping ${results.length - ownerResults.length} foreign webhook(s).` ) return ownerResults } if (results.length > 1) { logger.info(`[${options.requestId}] Found ${results.length} webhooks for path: ${options.path}`) } return results } /** * Finds all active `slack_app` webhooks for a Slack `team_id` (the routing key). * * Unlike path-based lookup, multi-workflow fan-out is legitimate here: a single * Slack workspace can have many workflows listening on the native Sim app, so * every matching workflow is returned. The routing key is server-derived at * deploy time (Slack-attested `team_id`), not user-controlled, so the * cross-tenant collision guard used for guessable paths does not apply. */ export async function findWebhooksByRoutingKey( routingKey: string, requestId: string, provider = 'slack_app' ): Promise> { if (!routingKey) { return [] } const results = await db .select({ webhook: webhook, workflow: workflow, }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) .leftJoin( workflowDeploymentVersion, and( eq(workflowDeploymentVersion.workflowId, workflow.id), eq(workflowDeploymentVersion.isActive, true) ) ) .where( and( eq(webhook.routingKey, routingKey), eq(webhook.provider, provider), eq(webhook.isActive, true), isNull(webhook.archivedAt), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) ) ) ) if (results.length === 0) { logger.warn(`[${requestId}] No active ${provider} webhooks for routing key`) } return results } function resolveEnvVars(value: string, envVars: Record): string { return resolveEnvVarReferences(value, envVars) as string } /** True when any string value in the provider config contains an env-var reference (`{{VAR}}`). */ function providerConfigReferencesEnvVars(config: Record): boolean { for (const value of Object.values(config)) { if (typeof value === 'string' && value.includes('{{')) { return true } } return false } function resolveProviderConfigEnvVars( config: Record, envVars: Record ): Record { const resolved: Record = {} for (const [key, value] of Object.entries(config)) { if (typeof value === 'string') { resolved[key] = resolveEnvVars(value, envVars) } else { resolved[key] = value } } return resolved } /** * Verify webhook provider authentication and signatures. * Delegates to the provider handler registry. */ export async function verifyProviderAuth( foundWebhook: any, foundWorkflow: any, request: NextRequest, rawBody: string, requestId: string ): Promise { const handler = getProviderHandler(foundWebhook.provider) const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} /** * Only fetch + decrypt the effective env when there is auth to verify AND the * provider config actually references env vars (`{{VAR}}`). This avoids a DB * read and decryption on the synchronous pre-ack path for the common case. */ let decryptedEnvVars: Record = {} if (handler.verifyAuth && providerConfigReferencesEnvVars(rawProviderConfig)) { try { decryptedEnvVars = await getEffectiveDecryptedEnv( foundWorkflow.userId, foundWorkflow.workspaceId ) } catch (error) { logger.error(`[${requestId}] Failed to fetch environment variables`, { error, }) } } const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars) if (handler.verifyAuth) { const authResult = await handler.verifyAuth({ webhook: foundWebhook, workflow: foundWorkflow, request, rawBody, requestId, providerConfig, }) if (authResult) return authResult } return null } /** * Run preprocessing checks for webhook execution */ export async function checkWebhookPreprocessing( foundWorkflow: any, foundWebhook: any, requestId: string ): Promise { try { const executionId = generateId() const correlation = { executionId, requestId, source: 'webhook' as const, workflowId: foundWorkflow.id, webhookId: foundWebhook.id, path: foundWebhook.path, provider: foundWebhook.provider, triggerType: 'webhook', } const preprocessResult = await preprocessExecution({ workflowId: foundWorkflow.id, userId: foundWorkflow.userId, triggerType: 'webhook', executionId, requestId, triggerData: { correlation }, checkRateLimit: true, checkDeployment: true, workspaceId: foundWorkflow.workspaceId, workflowRecord: foundWorkflow, }) if (!preprocessResult.success) { const error = preprocessResult.error! logger.warn(`[${requestId}] Webhook preprocessing failed`, { provider: foundWebhook.provider, error: error.message, statusCode: error.statusCode, }) return { error: formatProviderErrorResponse(foundWebhook, error.message, error.statusCode), } } return { error: null, actorUserId: preprocessResult.actorUserId, executionId, correlation, } } catch (preprocessError) { logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError) return { error: formatProviderErrorResponse(foundWebhook, 'Internal error during preprocessing', 500), } } } export type WebhookDispatchOutcome = 'queued' | 'ignored' | 'failed' export interface WebhookDispatchResult { outcome: WebhookDispatchOutcome response: NextResponse reason: | 'queued' | 'event-mismatch' | 'filtered' | 'preprocessing' | 'block-missing' | 'queue-failed' } type ResolvedWebhookRecord = Omit & { provider: string providerConfig: Record } function parseProviderConfig(value: unknown): Record { return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} } function getCredentialId(providerConfig: Record): string | undefined { return typeof providerConfig.credentialId === 'string' ? providerConfig.credentialId : undefined } function shouldUseDurableQueue(provider: string, handler: WebhookProviderHandler): boolean { return ( isPollingWebhookProvider(provider) || provider === SIM_TRIGGER_PROVIDER || handler.executionMode === 'queue' ) } async function queueWebhookExecutionWithResult( foundWebhook: ResolvedWebhookRecord, foundWorkflow: typeof workflow.$inferSelect, body: unknown, request: NextRequest, options: WebhookProcessorOptions ): Promise { const providerConfig = foundWebhook.providerConfig ?? {} const handler = getProviderHandler(foundWebhook.provider) try { if (handler.matchEvent) { const result = await handler.matchEvent({ webhook: foundWebhook, workflow: foundWorkflow, body, request, requestId: options.requestId, providerConfig, }) if (result !== true) { if (result instanceof NextResponse) { return { outcome: result.ok ? 'ignored' : 'failed', response: result, reason: 'event-mismatch', } } return { outcome: 'ignored', response: NextResponse.json({ message: 'Event type does not match trigger configuration. Ignoring.', }), reason: 'event-mismatch', } } } const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries()) if (handler.enrichHeaders) { handler.enrichHeaders( { webhook: foundWebhook, body, requestId: options.requestId, providerConfig }, headers ) } const credentialId = getCredentialId(providerConfig) const actorUserId = options.actorUserId if (!actorUserId) { logger.error(`[${options.requestId}] No actorUserId provided for webhook ${foundWebhook.id}`) return { outcome: 'failed', response: NextResponse.json( { error: 'Unable to resolve billing account' }, { status: 500 } ), reason: 'queue-failed', } } const executionId = options.executionId ?? generateId() const correlation = options.correlation ?? ({ executionId, requestId: options.requestId, source: 'webhook' as const, workflowId: foundWorkflow.id, webhookId: foundWebhook.id, // Routing-key webhooks (e.g. Slack) have no path. path: options.path || foundWebhook.path || undefined, provider: foundWebhook.provider, triggerType: 'webhook', } satisfies AsyncExecutionCorrelation) const workspaceId = foundWorkflow.workspaceId ?? undefined const payload = { webhookId: foundWebhook.id, workflowId: foundWorkflow.id, userId: actorUserId, executionId, requestId: options.requestId, correlation, provider: foundWebhook.provider, body, headers, path: options.path || foundWebhook.path || '', blockId: foundWebhook.blockId ?? undefined, workspaceId, ...(credentialId ? { credentialId } : {}), ...(options.receivedAt !== undefined ? { webhookReceivedAt: options.receivedAt } : {}), ...(options.triggerTimestampMs !== undefined ? { triggerTimestampMs: options.triggerTimestampMs } : {}), } const shouldUseQueue = shouldUseDurableQueue(payload.provider, handler) if (shouldUseQueue && !shouldExecuteInline()) { const jobId = await (await getJobQueue()).enqueue('webhook-execution', payload, { metadata: { workflowId: foundWorkflow.id, workspaceId, userId: actorUserId, correlation, }, }) logger.info( `[${options.requestId}] Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue` ) } else { const jobQueue = await getInlineJobQueue() const jobId = await jobQueue.enqueue('webhook-execution', payload, { metadata: { workflowId: foundWorkflow.id, workspaceId, userId: actorUserId, correlation, }, }) logger.info( `[${options.requestId}] Queued ${foundWebhook.provider} webhook execution ${jobId} via inline backend` ) /** * Inline runs in-process microseconds after the route resolved the actor, * so reuse it to skip a redundant billed-account lookup. Set only on the * in-process payload — the enqueued/persisted copy omits it so any deferred * re-run re-resolves the current billed account. */ const inlinePayload = { ...payload, resolvedActorUserId: actorUserId } void (async () => { try { await jobQueue.startJob(jobId) const output = await executeWebhookJob(inlinePayload) await jobQueue.completeJob(jobId, output) } catch (error) { const errorMessage = toError(error).message logger.error(`[${options.requestId}] Webhook execution failed`, { jobId, error: errorMessage, }) try { await jobQueue.markJobFailed(jobId, errorMessage) } catch (markFailedError) { logger.error(`[${options.requestId}] Failed to mark job as failed`, { jobId, error: markFailedError instanceof Error ? markFailedError.message : String(markFailedError), }) } } })() } const successResponse = handler.formatSuccessResponse?.(providerConfig) ?? null if (successResponse) { return { outcome: 'queued', response: successResponse, reason: 'queued' } } return { outcome: 'queued', response: NextResponse.json({ message: 'Webhook processed' }), reason: 'queued', } } catch (error: unknown) { logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error) const errorResponse = handler.formatQueueErrorResponse?.() ?? null if (errorResponse) { return { outcome: 'failed', response: errorResponse, reason: 'queue-failed' } } return { outcome: 'failed', response: NextResponse.json({ error: 'Internal server error' }, { status: 500 }), reason: 'queue-failed', } } } /** * Runs the common post-authentication lifecycle for a resolved webhook target and returns a typed * outcome so app-level fanout workers do not infer queue state from HTTP response bodies. */ export async function dispatchResolvedWebhookTarget( foundWebhook: typeof webhook.$inferSelect, foundWorkflow: typeof workflow.$inferSelect, body: unknown, request: NextRequest, options: WebhookProcessorOptions ): Promise { if (!foundWebhook.provider) { return { outcome: 'failed', response: NextResponse.json({ error: 'Webhook provider is missing' }, { status: 500 }), reason: 'queue-failed', } } const webhookRecord = { ...foundWebhook, provider: foundWebhook.provider, providerConfig: parseProviderConfig(foundWebhook.providerConfig), } const preprocessResult = await checkWebhookPreprocessing( foundWorkflow, webhookRecord, options.requestId ) if (preprocessResult.error) { return { outcome: 'failed', response: preprocessResult.error, reason: 'preprocessing', } } if (webhookRecord.blockId) { const blockExists = await blockExistsInDeployment(foundWorkflow.id, webhookRecord.blockId) if (!blockExists) { const verificationResponse = handlePreDeploymentVerification(webhookRecord, options.requestId) return { outcome: 'ignored', response: verificationResponse ?? new NextResponse('Trigger block not found in deployment', { status: 404 }), reason: 'block-missing', } } } if (shouldSkipWebhookEvent(webhookRecord, body, options.requestId)) { return { outcome: 'ignored', response: NextResponse.json({ message: 'Webhook event ignored' }), reason: 'filtered', } } return queueWebhookExecutionWithResult(webhookRecord, foundWorkflow, body, request, { ...options, actorUserId: preprocessResult.actorUserId, executionId: preprocessResult.executionId, correlation: preprocessResult.correlation, }) } export interface PolledWebhookEventResult { success: boolean error?: string statusCode?: number executionId?: string } type PolledWebhookRecord = typeof webhook.$inferSelect type PolledWorkflowRecord = typeof workflow.$inferSelect /** * Processes a polled webhook event directly, bypassing the HTTP trigger route. * Used by polling services (Gmail, Outlook, IMAP, RSS) to avoid the self-POST * anti-pattern where they would otherwise POST back to /api/webhooks/trigger/{path}. * * Performs only the steps actually needed for polling providers: * admission control, preprocessing, block existence check, and queue execution. */ export async function processPolledWebhookEvent( foundWebhook: PolledWebhookRecord, foundWorkflow: PolledWorkflowRecord, body: Record | object, requestId: string ): Promise { if (!foundWebhook.provider) { return { success: false, error: 'Webhook has no provider', statusCode: 400 } } const provider = foundWebhook.provider const ticket = tryAdmit() if (!ticket) { logger.warn(`[${requestId}] Admission gate rejected polled webhook event`) return { success: false, error: 'Server at capacity', statusCode: 429 } } try { const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) if (preprocessResult.error) { const errorResponse = preprocessResult.error const statusCode = errorResponse.status const errorBody = await errorResponse.json().catch(() => ({})) const errorMessage = errorBody.error ?? 'Preprocessing failed' logger.warn(`[${requestId}] Polled webhook preprocessing failed`, { statusCode, error: errorMessage, }) return { success: false, error: errorMessage, statusCode } } if (foundWebhook.blockId) { const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) if (!blockExists) { logger.info( `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}` ) return { success: false, error: 'Trigger block not found in deployment', statusCode: 404 } } } const providerConfig = parseProviderConfig(foundWebhook.providerConfig) const credentialId = getCredentialId(providerConfig) const actorUserId = preprocessResult.actorUserId if (!actorUserId) { logger.error(`[${requestId}] No actorUserId provided for webhook ${foundWebhook.id}`) return { success: false, error: 'Unable to resolve billing account', statusCode: 500 } } const executionId = preprocessResult.executionId ?? generateId() const correlation = preprocessResult.correlation ?? ({ executionId, requestId, source: 'webhook' as const, workflowId: foundWorkflow.id, webhookId: foundWebhook.id, path: foundWebhook.path ?? '', provider, triggerType: 'webhook', } satisfies AsyncExecutionCorrelation) const workspaceId = foundWorkflow.workspaceId ?? undefined const payload = { webhookId: foundWebhook.id, workflowId: foundWorkflow.id, userId: actorUserId, executionId, requestId, correlation, provider, body, headers: { 'content-type': 'application/json' } as Record, path: foundWebhook.path ?? '', blockId: foundWebhook.blockId ?? undefined, workspaceId, ...(credentialId ? { credentialId } : {}), } const isQueueRoutedProvider = shouldUseDurableQueue(provider, getProviderHandler(provider)) if (isQueueRoutedProvider && !shouldExecuteInline()) { const jobId = await (await getJobQueue()).enqueue('webhook-execution', payload, { metadata: { workflowId: foundWorkflow.id, workspaceId, userId: actorUserId, correlation, }, }) logger.info( `[${requestId}] Queued polling webhook execution task ${jobId} for ${provider} webhook via job queue` ) } else { const jobQueue = await getInlineJobQueue() const jobId = await jobQueue.enqueue('webhook-execution', payload, { metadata: { workflowId: foundWorkflow.id, workspaceId, userId: actorUserId, correlation, }, }) logger.info(`[${requestId}] Queued ${provider} webhook execution ${jobId} via inline backend`) void (async () => { try { await jobQueue.startJob(jobId) const output = await executeWebhookJob(payload) await jobQueue.completeJob(jobId, output) } catch (error) { const errorMessage = toError(error).message logger.error(`[${requestId}] Webhook execution failed`, { jobId, error: errorMessage, }) try { await jobQueue.markJobFailed(jobId, errorMessage) } catch (markFailedError) { logger.error(`[${requestId}] Failed to mark job as failed`, { jobId, error: markFailedError instanceof Error ? markFailedError.message : String(markFailedError), }) } } })() } return { success: true, executionId } } catch (error: unknown) { logger.error(`[${requestId}] Failed to process polled webhook event:`, error) return { success: false, error: 'Internal server error', statusCode: 500 } } finally { ticket.release() } }