import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { webhookTriggerGetContract, webhookTriggerPostContract } from '@/lib/api/contracts/webhooks' import { parseRequest } from '@/lib/api/server' import { API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, isWorkspaceApiExecutionEntitled, } from '@/lib/billing/core/api-access' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchResolvedWebhookTarget, findAllWebhooksForPath, handlePreLookupWebhookVerification, handleProviderChallenges, handleProviderReachabilityTest, parseWebhookBody, verifyProviderAuth, } from '@/lib/webhooks/processor' import { acceptsPathWebhookDelivery } from '@/lib/webhooks/providers' import { isInternalTriggerProvider } from '@/triggers/constants' const logger = createLogger('WebhookTriggerAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 60 export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ path: string }> }) => { const requestId = generateRequestId() const parsed = await parseRequest(webhookTriggerGetContract, request, context) if (!parsed.success) return parsed.response const { path } = parsed.data.params // Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.) const challengeResponse = await handleProviderChallenges({}, request, requestId, path) if (challengeResponse) { return challengeResponse } return ( (await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) || new NextResponse('Method not allowed', { status: 405 }) ) } ) export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ path: string }> }) => { const ticket = tryAdmit() if (!ticket) { return admissionRejectedResponse() } try { return await handleWebhookPost(request, context) } finally { ticket.release() } } ) async function handleWebhookPost( request: NextRequest, context: { params: Promise<{ path: string }> } ): Promise { const receivedAt = Date.now() /** * Slack signs every interactive request with the originating interaction time. * Capturing it lets the executor surface the true trigger_id age (the window * that expires at 3s) instead of only the in-workflow block timings. */ const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') const triggerTimestampMs = slackRequestTimestamp ? Number(slackRequestTimestamp) * 1000 : undefined const requestId = generateRequestId() const parsed = await parseRequest(webhookTriggerPostContract, request, context) if (!parsed.success) return parsed.response const { path } = parsed.data.params const earlyChallenge = await handleProviderChallenges({}, request, requestId, path) if (earlyChallenge) { return earlyChallenge } const parseResult = await parseWebhookBody(request, requestId) // Check if parseWebhookBody returned an error response if (parseResult instanceof NextResponse) { return parseResult } const { body, rawBody } = parseResult const challengeResponse = await handleProviderChallenges(body, request, requestId, path, rawBody) if (challengeResponse) { return challengeResponse } // Find all webhooks for this path (multiple webhooks in one workflow may share a path) const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path }) /** Exclude in-process triggers and providers that own an app-level ingress route. */ const webhooksForPath = allWebhooksForPath.filter( ({ webhook: foundWebhook }) => !isInternalTriggerProvider(foundWebhook.provider) && acceptsPathWebhookDelivery(foundWebhook.provider) ) if (allWebhooksForPath.length > 0 && webhooksForPath.length === 0) { logger.warn(`[${requestId}] Rejected HTTP delivery to non-path trigger: ${path}`) return new NextResponse('Not Found', { status: 404 }) } if (webhooksForPath.length === 0) { const verificationResponse = await handlePreLookupWebhookVerification( request.method, body as Record | undefined, requestId, path ) if (verificationResponse) { return verificationResponse } logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`) return new NextResponse('Not Found', { status: 404 }) } // Process each webhook matched on this path const responses: NextResponse[] = [] let billingBlocked = false for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) { // Generic ("custom") webhooks are an unauthenticated programmatic execution // surface, so they fall under the same paid-plan gate as the API. Provider // webhooks (slack, github, ...) are unaffected. if ( foundWebhook.provider === 'generic' && !(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId)) ) { logger.warn(`[${requestId}] Generic webhook blocked: workspace on free plan`) billingBlocked = true if (webhooksForPath.length > 1) continue return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) } const authError = await verifyProviderAuth( foundWebhook, foundWorkflow, request, rawBody, requestId ) if (authError) { if (webhooksForPath.length > 1) { logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`) continue } return authError } const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId) if (reachabilityResponse) { return reachabilityResponse } const dispatchResult = await dispatchResolvedWebhookTarget( foundWebhook, foundWorkflow, body, request, { requestId, path, receivedAt, triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined, } ) if (dispatchResult.reason === 'filtered') { continue } if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') { if (webhooksForPath.length > 1) { logger.warn( `[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`, { reason: dispatchResult.reason, status: dispatchResult.response.status } ) continue } return dispatchResult.response } responses.push(dispatchResult.response) } if (responses.length === 0) { if (billingBlocked) { return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) } return new NextResponse('No webhooks processed successfully', { status: 500 }) } if (responses.length === 1) { return responses[0] } // For multiple webhooks, return success if at least one succeeded logger.info(`[${requestId}] Processed ${responses.length} webhooks for path: ${path}`) return NextResponse.json({ success: true, webhooksProcessed: responses.length, }) }