Skip to content

Commit 9dbd44e

Browse files
aadamgoughAdam Gough
andauthored
fix(webhook-payloads): fixed the variable resolution in webhooks (#1019)
* telegram webhook fix * changed payloads * test * test * test * test * fix github dropdown * test * reverted github changes * fixed github var * test * bun run lint * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test * test push * test * bun run lint * edited airtable payload and webhook deletion * Revert bun.lock and package.json to upstream/staging * cleaned up * test * test * resolving more cmments * resolved comments, updated trigger * cleaned up, resolved comments * test * test * lint --------- Co-authored-by: Adam Gough <[email protected]>
1 parent 9ea9f2d commit 9dbd44e

File tree

7 files changed

+316
-144
lines changed

7 files changed

+316
-144
lines changed

apps/sim/app/api/webhooks/[id]/route.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { eq } from 'drizzle-orm'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { getSession } from '@/lib/auth'
4+
import { env } from '@/lib/env'
45
import { createLogger } from '@/lib/logs/console/logger'
56
import { getUserEntityPermissions } from '@/lib/permissions/utils'
7+
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
68
import { db } from '@/db'
79
import { webhook, workflow } from '@/db/schema'
810

@@ -242,6 +244,167 @@ export async function DELETE(
242244

243245
const foundWebhook = webhookData.webhook
244246

247+
// If it's an Airtable webhook, delete it from Airtable first
248+
if (foundWebhook.provider === 'airtable') {
249+
try {
250+
const { baseId, externalId } = (foundWebhook.providerConfig || {}) as {
251+
baseId?: string
252+
externalId?: string
253+
}
254+
255+
if (!baseId) {
256+
logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion.`, {
257+
webhookId: id,
258+
})
259+
return NextResponse.json(
260+
{ error: 'Missing baseId for Airtable webhook deletion' },
261+
{ status: 400 }
262+
)
263+
}
264+
265+
// Get access token for the workflow owner
266+
const userIdForToken = webhookData.workflow.userId
267+
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
268+
if (!accessToken) {
269+
logger.warn(
270+
`[${requestId}] Could not retrieve Airtable access token for user ${userIdForToken}. Cannot delete webhook in Airtable.`,
271+
{ webhookId: id }
272+
)
273+
return NextResponse.json(
274+
{ error: 'Airtable access token not found for webhook deletion' },
275+
{ status: 401 }
276+
)
277+
}
278+
279+
// Resolve externalId if missing by listing webhooks and matching our notificationUrl
280+
let resolvedExternalId: string | undefined = externalId
281+
282+
if (!resolvedExternalId) {
283+
try {
284+
const requestOrigin = new URL(request.url).origin
285+
const effectiveOrigin = requestOrigin.includes('localhost')
286+
? env.NEXT_PUBLIC_APP_URL || requestOrigin
287+
: requestOrigin
288+
const expectedNotificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${foundWebhook.path}`
289+
290+
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
291+
const listResp = await fetch(listUrl, {
292+
headers: {
293+
Authorization: `Bearer ${accessToken}`,
294+
},
295+
})
296+
const listBody = await listResp.json().catch(() => null)
297+
298+
if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) {
299+
const match = listBody.webhooks.find((w: any) => {
300+
const url: string | undefined = w?.notificationUrl
301+
if (!url) return false
302+
// Prefer exact match; fallback to suffix match to handle origin/host remaps
303+
return (
304+
url === expectedNotificationUrl ||
305+
url.endsWith(`/api/webhooks/trigger/${foundWebhook.path}`)
306+
)
307+
})
308+
if (match?.id) {
309+
resolvedExternalId = match.id as string
310+
// Persist resolved externalId for future operations
311+
try {
312+
await db
313+
.update(webhook)
314+
.set({
315+
providerConfig: {
316+
...(foundWebhook.providerConfig || {}),
317+
externalId: resolvedExternalId,
318+
},
319+
updatedAt: new Date(),
320+
})
321+
.where(eq(webhook.id, id))
322+
} catch {
323+
// non-fatal persistence error
324+
}
325+
logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, {
326+
baseId,
327+
externalId: resolvedExternalId,
328+
})
329+
} else {
330+
logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, {
331+
baseId,
332+
expectedNotificationUrl,
333+
})
334+
}
335+
} else {
336+
logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, {
337+
baseId,
338+
status: listResp.status,
339+
body: listBody,
340+
})
341+
}
342+
} catch (e: any) {
343+
logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, {
344+
error: e?.message,
345+
})
346+
}
347+
}
348+
349+
// If still not resolvable, skip remote deletion but proceed with local delete
350+
if (!resolvedExternalId) {
351+
logger.info(
352+
`[${requestId}] Airtable externalId not found; skipping remote deletion and proceeding to remove local record`,
353+
{ baseId }
354+
)
355+
}
356+
357+
if (resolvedExternalId) {
358+
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
359+
const airtableResponse = await fetch(airtableDeleteUrl, {
360+
method: 'DELETE',
361+
headers: {
362+
Authorization: `Bearer ${accessToken}`,
363+
},
364+
})
365+
366+
// Attempt to parse error body for better diagnostics
367+
if (!airtableResponse.ok) {
368+
let responseBody: any = null
369+
try {
370+
responseBody = await airtableResponse.json()
371+
} catch {
372+
// ignore parse errors
373+
}
374+
375+
logger.error(
376+
`[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`,
377+
{ baseId, externalId: resolvedExternalId, response: responseBody }
378+
)
379+
return NextResponse.json(
380+
{
381+
error: 'Failed to delete webhook from Airtable',
382+
details:
383+
(responseBody && (responseBody.error?.message || responseBody.error)) ||
384+
`Status ${airtableResponse.status}`,
385+
},
386+
{ status: 500 }
387+
)
388+
}
389+
390+
logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, {
391+
baseId,
392+
externalId: resolvedExternalId,
393+
})
394+
}
395+
} catch (error: any) {
396+
logger.error(`[${requestId}] Error deleting Airtable webhook`, {
397+
webhookId: id,
398+
error: error.message,
399+
stack: error.stack,
400+
})
401+
return NextResponse.json(
402+
{ error: 'Failed to delete webhook from Airtable', details: error.message },
403+
{ status: 500 }
404+
)
405+
}
406+
}
407+
245408
// If it's a Telegram webhook, delete it from Telegram first
246409
if (foundWebhook.provider === 'telegram') {
247410
try {

apps/sim/executor/handlers/trigger/trigger-handler.ts

Lines changed: 29 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ export class TriggerBlockHandler implements BlockHandler {
2727
): Promise<any> {
2828
logger.info(`Executing trigger block: ${block.id} (Type: ${block.metadata?.id})`)
2929

30+
// If this trigger block was initialized with a precomputed output in the execution context
31+
// (e.g., webhook payload injected at init), return it as-is to preserve the raw shape.
32+
const existingState = context.blockStates.get(block.id)
33+
if (existingState?.output && Object.keys(existingState.output).length > 0) {
34+
return existingState.output
35+
}
36+
3037
// For trigger blocks, return the starter block's output which contains the workflow input
3138
// This ensures webhook data like message, sender, chat, etc. are accessible
3239
const starterBlock = context.workflow?.blocks?.find((b) => b.metadata?.id === 'starter')
@@ -36,17 +43,32 @@ export class TriggerBlockHandler implements BlockHandler {
3643
const starterOutput = starterState.output
3744

3845
// Generic handling for webhook triggers - extract provider-specific data
39-
// Check if this is a webhook execution with nested structure
46+
47+
// Check if this is a webhook execution
4048
if (starterOutput.webhook?.data) {
41-
const webhookData = starterOutput.webhook.data
49+
const webhookData = starterOutput.webhook?.data || {}
4250
const provider = webhookData.provider
4351

4452
logger.debug(`Processing webhook trigger for block ${block.id}`, {
4553
provider,
4654
blockType: block.metadata?.id,
4755
})
4856

49-
// Extract the flattened properties that should be at root level
57+
// Provider-specific early return for GitHub: expose raw payload at root
58+
if (provider === 'github') {
59+
const payloadSource = webhookData.payload || {}
60+
return {
61+
...payloadSource,
62+
webhook: starterOutput.webhook,
63+
}
64+
}
65+
66+
// Provider-specific early return for Airtable: preserve raw shape entirely
67+
if (provider === 'airtable') {
68+
return starterOutput
69+
}
70+
71+
// Extract the flattened properties that should be at root level (non-GitHub/Airtable)
5072
const result: any = {
5173
// Always keep the input at root level
5274
input: starterOutput.input,
@@ -67,70 +89,17 @@ export class TriggerBlockHandler implements BlockHandler {
6789
const providerData = starterOutput[provider]
6890

6991
for (const [key, value] of Object.entries(providerData)) {
70-
// Special handling for GitHub provider - copy all properties
71-
if (provider === 'github') {
72-
// For GitHub, copy all properties (objects and primitives) to root level
92+
// For other providers, keep existing logic (only copy objects)
93+
if (typeof value === 'object' && value !== null) {
94+
// Don't overwrite existing top-level properties
7395
if (!result[key]) {
74-
// Special handling for complex objects that might have enumeration issues
75-
if (typeof value === 'object' && value !== null) {
76-
try {
77-
// Deep clone complex objects to avoid reference issues
78-
result[key] = JSON.parse(JSON.stringify(value))
79-
} catch (error) {
80-
// If JSON serialization fails, try direct assignment
81-
result[key] = value
82-
}
83-
} else {
84-
result[key] = value
85-
}
86-
}
87-
} else {
88-
// For other providers, keep existing logic (only copy objects)
89-
if (typeof value === 'object' && value !== null) {
90-
// Don't overwrite existing top-level properties
91-
if (!result[key]) {
92-
result[key] = value
93-
}
96+
result[key] = value
9497
}
9598
}
9699
}
97100

98101
// Keep nested structure for backwards compatibility
99102
result[provider] = providerData
100-
101-
// Special handling for GitHub complex objects that might not be copied by the main loop
102-
if (provider === 'github') {
103-
// Comprehensive GitHub object extraction from multiple possible sources
104-
const githubObjects = ['repository', 'sender', 'pusher', 'commits', 'head_commit']
105-
106-
for (const objName of githubObjects) {
107-
// ALWAYS try to get the object, even if something exists (fix for conflicts)
108-
let objectValue = null
109-
110-
// Source 1: Direct from provider data
111-
if (providerData[objName]) {
112-
objectValue = providerData[objName]
113-
}
114-
// Source 2: From webhook payload (raw GitHub webhook)
115-
else if (starterOutput.webhook?.data?.payload?.[objName]) {
116-
objectValue = starterOutput.webhook.data.payload[objName]
117-
}
118-
// Source 3: For commits, try parsing JSON string version if no object found
119-
else if (objName === 'commits' && typeof result.commits === 'string') {
120-
try {
121-
objectValue = JSON.parse(result.commits)
122-
} catch (e) {
123-
// Keep as string if parsing fails
124-
objectValue = result.commits
125-
}
126-
}
127-
128-
// FORCE the object to root level (removed the !result[objName] condition)
129-
if (objectValue !== null && objectValue !== undefined) {
130-
result[objName] = objectValue
131-
}
132-
}
133-
}
134103
}
135104

136105
// Pattern 2: Provider data directly in webhook.data (based on actual structure)

apps/sim/lib/webhooks/utils.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -607,19 +607,9 @@ export function formatWebhookInput(
607607
}
608608

609609
return {
610-
input, // Primary workflow input
611-
612-
// Top-level properties for backward compatibility
613-
...githubData,
614-
615-
// GitHub data structured for trigger handler to extract
616-
github: {
617-
// Processed convenience variables
618-
...githubData,
619-
// Raw GitHub webhook payload for direct field access
620-
...body,
621-
},
622-
610+
// Expose raw GitHub payload at the root
611+
...body,
612+
// Include webhook metadata alongside
623613
webhook: {
624614
data: {
625615
provider: 'github',
@@ -835,6 +825,8 @@ export async function fetchAndProcessAirtablePayloads(
835825
let apiCallCount = 0
836826
// Use a Map to consolidate changes per record ID
837827
const consolidatedChangesMap = new Map<string, AirtableChange>()
828+
// Capture raw payloads from Airtable for exposure to workflows
829+
const allPayloads = []
838830
const localProviderConfig = {
839831
...((webhookData.providerConfig as Record<string, any>) || {}),
840832
} // Local copy
@@ -1031,6 +1023,10 @@ export async function fetchAndProcessAirtablePayloads(
10311023
// --- Process and Consolidate Changes ---
10321024
if (receivedPayloads.length > 0) {
10331025
payloadsFetched += receivedPayloads.length
1026+
// Keep the raw payloads for later exposure to the workflow
1027+
for (const p of receivedPayloads) {
1028+
allPayloads.push(p)
1029+
}
10341030
let changeCount = 0
10351031
for (const payload of receivedPayloads) {
10361032
if (payload.changedTablesById) {
@@ -1196,10 +1192,25 @@ export async function fetchAndProcessAirtablePayloads(
11961192
)
11971193

11981194
// --- Execute Workflow if we have changes (simplified - no lock check) ---
1199-
if (finalConsolidatedChanges.length > 0) {
1195+
if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) {
12001196
try {
1201-
// Format the input for the executor using the consolidated changes
1202-
const input = { airtableChanges: finalConsolidatedChanges } // Use the consolidated array
1197+
// Build input exposing raw payloads and consolidated changes
1198+
const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null
1199+
const input: any = {
1200+
// Raw Airtable payloads as received from the API
1201+
payloads: allPayloads,
1202+
latestPayload,
1203+
// Consolidated, simplified changes for convenience
1204+
airtableChanges: finalConsolidatedChanges,
1205+
// Include webhook metadata for resolver fallbacks
1206+
webhook: {
1207+
data: {
1208+
provider: 'airtable',
1209+
providerConfig: webhookData.providerConfig,
1210+
payload: latestPayload,
1211+
},
1212+
},
1213+
}
12031214

12041215
// CRITICAL EXECUTION TRACE POINT
12051216
logger.info(
@@ -1216,6 +1227,7 @@ export async function fetchAndProcessAirtablePayloads(
12161227
logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, {
12171228
workflowId: workflowData.id,
12181229
recordCount: finalConsolidatedChanges.length,
1230+
rawPayloadCount: allPayloads.length,
12191231
timestamp: new Date().toISOString(),
12201232
})
12211233

0 commit comments

Comments
 (0)